Destinations API
Activate your resolved event data by forwarding conversions to webhooks, the Meta Conversions API, and Google Ads, with per-destination credentials, delivery logs, and health monitoring.
Overview
The Destinations API is the partner self-serve surface for data activation. A destination forwards qualifying events to an external system when its trigger rules match. You manage destinations, store the credentials they need, and inspect what was delivered — all scoped to a single client location.
Base URL
All endpoints are mounted under the /client/v1/destinations prefix.
https://mythic-analytics.gulp.workers.dev/client/v1/destinations
Destination types
Six destination types are supported. The action_config you send is validated against the action_type.
| Type | action_type | What it does | Credential |
|---|---|---|---|
| Webhook | webhook | POSTs the event to an http(s) URL you control | Inline in action_config (headers / body template) |
| Meta Conversions API | meta_capi | Sends server-side conversions to a Meta pixel | Stored separately (meta_capi) |
| Google Ads | google_ads | Uploads conversions to a Google Ads customer via the Data Manager API | Stored separately (google_ads) |
| Google Analytics 4 | google_analytics | Sends events to a GA4 property via the same Data Manager API | Stored separately (google_analytics) |
| OpenAI (ChatGPT) Ads | openai_ads | Sends server-side conversions to an OpenAI Ads pixel | Stored separately (openai_ads) |
| Convert.com experiments | convert_experiments | Credits an off-browser conversion to the A/B variation the visitor was in | None for delivery (see Convert.com experiments) |
tiktok_events is recognized by the schema but has no executor yet, so creating it returns unsupported_destination_type. Only webhook, meta_capi, google_ads, google_analytics, openai_ads and convert_experiments are live.
google_ads and google_analytics call the same endpoint with the same
credential shape and differ only in the destination they address. They are
separate types because they want opposite event cardinality: GA4 takes many
events under open-ended names, Google Ads takes a few bound to conversion
actions. Keeping them apart means one set of trigger rules, one enable switch,
and one delivery log per platform — turning on GA4 pageviews cannot start
firing Ads conversions.
Which one you want. Use meta_capi or google_ads when the goal is ad
optimization or attribution inside that platform — they hash and send identifiers
in the shape each API expects, deduplicate against the browser pixel, and let you
report an offline conversion the tag never saw. Use webhook when the receiver is
something you control: your own CRM, warehouse, queue, Zapier/Make, or an
internal service. It is the escape hatch for any system Mythic has no adapter for,
and because it now runs through the same pipeline as the ad destinations, you get
the merged person profile, delivery logs, retries, and auto-disable there too —
so reaching for a webhook is no longer a downgrade in observability.
Destinations are independent: a single event can fan out to a webhook and both ad platforms at once, each with its own trigger rules and mappings.
Webhook
Requires a fetchable url. Optional method, headers, body_template, timeout_ms, and retry_count. Because webhook credentials live inline in action_config, header values and body_template are redacted to *** when read with a secret (viewer) key. HIPAA-enabled locations reject http:// URLs at write time.
Webhooks run through the same delivery pipeline as the ad destinations, so they get identical profile enrichment (on by default), delivery logging, payload capture, retry classification, and auth-failure auto-disable. body_template supports {{profile.x}} tokens filled from the merged person profile; set enrichment.enabled: false to opt out, in which case those tokens are left verbatim instead of blanked.
enrichment.full_profile used to be the opt-in switch for profile data and is now ignored — enrichment is the default. Actions that never set it now receive enrichment where they previously did not.
Unlike the ad destinations, webhooks are filtered by trigger_rules alone (there is no event_mapping). Note that every client-side rule type — url_pattern, cookie, device, time, javascript_variable, custom_js, query_param — always passes server-side, so a webhook whose only rule is one of those matches every event for the location. Pair it with a mythic_event rule unless that is what you want.
Convert.com experiments
Requires account_id, project_id and goal_id — all Convert dashboard ids, all numeric. Optional send_revenue and timeout_ms. Which events reach the goal is decided by trigger_rules; there is no event_mapping.
This is for the conversions your A/B tool cannot see. Convert's own script only counts what happens in the browser, so a CRM close, a phone sale or a subscription rebill never reaches the experiment — and those are frequently the conversions that decide which variation actually won. The Mythic SDK captures Convert's visitor id and the variation from Convert's cookies and stores both on the person profile; this destination replays the conversion under that id whenever it arrives, days or weeks later.
It follows that the destination only fires for people who were in a running Convert experiment while they were on the site. Everyone else is logged as skipped with a reason (no $convert_visitor_id on person / no $convert_experiments on person). That is the normal state for most of your traffic, not a fault.
{
"name": "Closed-won → Convert",
"action_type": "convert_experiments",
"trigger_rules": { "operator": "AND", "rules": [
{ "type": "mythic_event", "eventName": "transaction_completed", "operator": "equals" }
]},
"action_config": {
"account_id": "100414195",
"project_id": "100415644",
"goal_id": "1004125117",
"send_revenue": true
}
}
One conversion per person per goal, ever. Convert counts a visitor once per goal and ignores every later hit for that pair — this is Convert's behaviour, not a Mythic limit. A customer's second purchase adds nothing to the conversion count. Revenue is the exception: it accumulates per transaction, which is why send_revenue is the only way repeat purchases show up at all.
Revenue. Conversions and revenue are separate counters in Convert with separate de-duplication rules, so send_revenue is additive — the bare conversion is still sent. It is only meaningful when goal_id names a Convert goal of type Revenue; pointed at any other goal type, the effect is undefined. The amount comes from the conversion event's value, and a missing, zero or unparseable value sends no revenue rather than a number we cannot stand behind.
There is no delivery credential. Deliveries use Convert's documented sendTrackingEvents operation, POST /track/{account_id}/{project_id}, which their own spec marks as taking no authentication. That also means anyone who knows your account and project ids can write conversions into your project — that is Convert's design for this operation, not something this destination can configure away. Convert's spec does list a second, authenticated operation on a different path; this destination does not use it, so there is one delivery path and it is the documented one.
The endpoint returns 200 for everything, including payloads it discards — so a success in the delivery log means "accepted", never "counted". Use verification to find out what actually landed.
OpenAI (ChatGPT) Ads
Requires a pixel_id and an event_mapping. Optional data_mapping, action_source, default_source_url, default_country_code, validate_only, credential_name, timeout_ms, and enrichment. The credential is the Conversions API key from the Conversions tab in OpenAI Ads Manager, stored separately as an openai_ads credential.
The event taxonomy is closed. OpenAI accepts thirteen event types and nothing else, and each one pins the shape of the payload's data object — so event_mapping targets must be one of order_created, checkout_started, items_added, contents_viewed, page_viewed, lead_created, appointment_scheduled, registration_completed, app_installed, app_opened, subscription_created, trial_started, or custom:<name> for anything outside it. A name outside the list is rejected when you create the destination rather than failing on every delivery.
curl -X POST https://mythic-analytics.gulp.workers.dev/client/v1/destinations \
-H "Authorization: Bearer ak_your_agency_key" \
-H "X-Location-Id: loc_abc123" \
-H "Content-Type: application/json" \
-d '{
"name": "Orders → ChatGPT Ads",
"action_type": "openai_ads",
"trigger_rules": {"rules":[{"type":"mythic_event","eventName":"transaction_completed","operator":"equals"}]},
"action_config": {
"pixel_id": "px_1234567890",
"event_mapping": {"transaction_completed": "order_created"}
}
}'
The click id is oppref, appended to your landing-page URL when someone clicks a ChatGPT ad. The Mythic SDK captures it like any other click id, the identity resolver stores it on the person, and it is replayed on a later conversion — so a purchase that happens days after the click still carries the click that earned it. Without it OpenAI can count the conversion but cannot tell you which ad, ad group or campaign produced it, and conversion-optimized bidding learns nothing from it. If you also run OpenAI's own OAIQ pixel on the page, its __obref browser reference is picked up and sent alongside, which is what lets their side dedupe browser and server events; the event uuid is sent as the dedup id, so reuse it as the pixel's event_id if you fire both.
Conversions older than 7 days are rejected by OpenAI, not by us. A subscription rebill, a replayed backfill, or a conversion credited to a click from last month lands in the delivery log as a permanent failure naming its age — deliberately, rather than as a silent skip. It is the one destination where a slow CRM sync loses conversions outright.
Monetary values are sent as an integer in the currency's minor unit ($129.99 becomes 12999), converted per currency — so JPY is not multiplied and KWD is multiplied by 1000. amount is only sent when a currency is known, since an integer with no unit is unreadable. Geo (city, region, postal, country) goes as plaintext on this API rather than hashed, so it is suppressed entirely for HIPAA-enabled locations, along with the client IP.
Meta Conversions API
Requires a pixel_id and an event_mapping object that maps each source event name to a Meta event name (for example {"purchase": "Purchase"}). Optional test_event_code, action_source, credential_name, default_country_code, fbc_subdomain_index, and enrichment.
user_data is assembled for match quality rather than minimalism. em and ph carry every email and phone the identity resolver has stitched to the person, not just the one on the event — Meta matches against each value in the array, so a contact known by a work address in your CRM and a personal one from the pixel matches on either. Phone numbers are hashed as digits with no +, and both the national and country-coded forms are sent unless default_country_code resolves the ambiguity. If no fbc was ever observed but a raw fbclid is known, an fbc is constructed from it using Meta's documented server-side format. The Meta access token is stored separately as a meta_capi credential. When IP/geo enrichment is enabled for your location, the server-side client IP and coarse geo (city, region, postal, country) are captured at ingest and forwarded automatically as client_ip_address plus hashed ct/st/zp/country in user_data — no client-side changes needed. It is off by default (enable it for your location to opt in) and always suppressed for HIPAA-enabled locations.
Google Ads
Conversions are delivered through Google's Data Manager API (events:ingest).
Requires a 10-digit customer_id (no dashes — the Data Manager operatingAccount) and an event_mapping object mapping each source event name to a conversion spec with a conversion_action (the bare conversion action ID, used as the productDestinationId) plus optional value_property, default_value, and currency. Optional enhanced_conversions_for_leads, event_source (WEB default; also APP, IN_STORE, PHONE, MESSAGE, OTHER), default_country_code, and consent (ad_user_data / ad_personalization, each GRANTED or DENIED).
Credentials are stored separately as a google_ads credential — a JSON object containing client_id, client_secret, refresh_token, and an optional login_customer_id for manager-account access. There is no developer token: the Data Manager API authorizes on the OAuth scope alone, so the refresh token must be minted against https://www.googleapis.com/auth/datamanager and the API must be enabled on the Cloud project behind the OAuth client.
A refresh token carrying the older adwords scope refreshes successfully and then fails at upload time with a permission error. If a Google Ads destination returns 403, check the scope before anything else.
Google Analytics: a 200 does not mean the data arrived
The Data Manager API accepts an event and processes it later. A successful delivery in the log means Google took the request, not that the event was ingested and not that it is visible yet. Two consequences that will otherwise cost you an afternoon:
- Ingestion is asynchronous. In practice it takes around 30 minutes to go
from
PROCESSINGtoSUCCESS. Checking GA4 seconds after a delivery shows nothing, correctly. - DebugView and Realtime are not valid tests. There is no
debug_modeequivalent in this API, and Data Manager is a backend data source rather than a tag hit, so it does not flow through the realtime pipeline. Look in standard reports, or the BigQuery export.
To find out what actually happened, ask Google:
curl "https://mythic-analytics.gulp.workers.dev/client/v1/destinations/$ID/deliveries/$EVENT_UUID/status" \
-H "Authorization: Bearer ak_your_agency_key" -H "X-Location-Id: loc_abc123"
{ "request_status": "SUCCESS", "record_count": "1", "error_counts": [], "warning_counts": [] }
PROCESSING is the normal state for a while — it is not a failure. Only FAILED and PARTIAL_SUCCESS carry error_counts, and those name the reason and the number of records affected.
Without this endpoint a destination that is quietly discarding every event is indistinguishable from one that works: both produce an unbroken run of 200s.
transaction_id is not a per-event id
Set transaction_id (or point transaction_id_property at it) only for a
real order identifier. It is how Google dedupes against your tag, and it is
what marks an event as a multi-source event matched to an existing tag event —
a mode that is allowlist-only per property. A random per-event value both
misclassifies the event and can never match anything. Pageviews should carry no
transaction id at all.
Connecting Google without a refresh token
You do not have to mint a refresh token by hand. POST /destinations/oauth/initiate returns a consent_url; send the browser there, the user picks a Google account, and the callback stores a credential for you. It is the same credential shape either way — the consent flow is a better way to obtain it, not a different one.
curl -X POST https://mythic-analytics.gulp.workers.dev/client/v1/destinations/oauth/initiate \
-H "Authorization: Bearer ak_your_agency_key" \
-H "X-Location-Id: loc_abc123" \
-H "Content-Type: application/json" \
-d '{"destination_type":"google_analytics","return_url":"https://portal.example.com/settings"}'
The consent_url is valid for 15 minutes and is single-use. On completion the browser is sent to return_url with mythic_oauth=success (or mythic_oauth=error&mythic_error=<code>) appended; omit return_url and the callback renders a terminal page instead.
A return_url origin must be registered on your agency first. The callback is unauthenticated by construction — a browser returning from Google carries no key — so an arbitrary redirect target would make it an open redirector. Unregistered origins are rejected at initiate, before anything is stored.
Whose OAuth client
By default consents run as Mythic's OAuth client and there is nothing to configure. Register your own with PUT /destinations/oauth/client (client_id + client_secret) to get:
- your brand on the consent screen instead of "Mythic Analytics",
- your own Cloud project's quota. Data Manager quota is per Cloud project (300 requests/min, 100,000/day) and Google Ads conversions are sent one per request, so everyone sharing Mythic's client shares a single 100,000/day conversion ceiling,
- your own Google verification, for your own consent screen.
The response echoes the redirect_uri you must add to your OAuth client's Authorized redirect URIs — it has to match exactly, and it is the most common setup mistake. GET /destinations/oauth/client reports has_client, the active client_id and redirect_uri; the secret is never returned. DELETE reverts to Mythic's client and leaves existing credentials working, since they were minted against the client that issued them.
If Google returns no refresh token, the connection fails rather than storing a short-lived one — otherwise the destination would report success and stop working within the hour. This normally means the account had already granted access; remove the app under Google account permissions and connect again.
Hashed user identifiers are attached whenever available, including alongside a click ID — that is what lifts match rate. Every stitched email and phone is sent, not just the one on the event, up to Google's ceiling of 10 identifiers per event; an address identifier always keeps a reserved slot so a long tail of email aliases cannot crowd it out. enhanced_conversions_for_leads governs only whether an event with no gclid/gbraid/wbraid may still be uploaded; otherwise such events are logged as skipped. Google requires E.164 phone numbers, so a 10-digit national number needs default_country_code to be usable — without it the phone identifier is dropped rather than hashed into a guaranteed non-match. Addresses send only when first name, last name, a 2-letter country code, and a postal code are all present. The event uuid is sent as Google's transactionId for deduplication.
Identifiers are normalized to Google's spec, which differs from Meta's in two
ways that are invisible when you get them wrong — Google returns 200 and
simply matches nobody. Emails on gmail.com/googlemail.com have dots and
+tags stripped from the local part (Cloudy.SanFrancisco+shopping@gmail.com
→ cloudysanfrancisco@gmail.com); every other domain keeps them. Names are
lowercased and trimmed at the ends only, so internal spaces, hyphens and
apostrophes are preserved (Mary Jane is hashed as mary jane, not
maryjane).
Richer conversion signal
All optional, all shared with google_analytics:
| Field | What it adds |
|---|---|
user_properties | customerType (NEW/RETURNING/REENGAGED) and customerValueBucket. customerType is what Google Ads' new-customer acquisition goal bids against. |
cart_data | Line items, unit prices, quantities, coupons, Merchant Center ids — Shopping and value-based bidding signals. |
custom_variables | Conversion custom variables, for value rules and segmentation. |
conversion_count_property | Quantity for counting-based conversions. |
last_updated_property | Restatements — re-upload a refunded or repriced order under the same transactionId. |
event_location | Store id and coarse geo. Required for Store Sales. |
Device context (userAgent, IP, browser, screen) is attached automatically from data already captured at ingest, as both eventDeviceInfo and adIdentifiers.landingPageDeviceInfo.
user_properties.derive_customer_type infers NEW/RETURNING from the
resolved person's session count. It is off by default and should stay off
for ecommerce: sessions are not purchases, so a shopper who browsed three
times before their first order is reported RETURNING, which mislabels a
genuinely new customer and suppresses the bid uplift the acquisition goal
exists to apply. Use customer_type_property and your own order count.
Google Analytics 4
GA4 events are delivered through the same Data Manager events:ingest
endpoint as Google Ads. This is Google's official upgrade path from the
Measurement Protocol: no API secret, and a real
error model instead of MP's silent 204 for anything not sent to its separate
validation server.
Requires a numeric property_id (the operatingAccount) and exactly one stream
target — measurement_id (G-XXXXXXXXXX, a web stream) or firebase_app_id
(an app stream), never both. event_mapping maps each source event name to a GA4
event name as a plain string, unlike google_ads' conversion-spec object.
Credentials are stored as a google_analytics credential with the same JSON
shape and the same https://www.googleapis.com/auth/datamanager scope as
google_ads.
GA4 event names must start with a letter, contain only letters, digits and
underscores, be at most 40 characters, and avoid the reserved prefixes ga_,
google_, firebase_ and _. These are rejected when you save the
destination, because GA4 itself accepts an invalid name at ingest and drops
the event later during processing — the destination would report healthy
deliveries while nothing arrived in the property.
Identity is the thing to get right. GA4 attaches an event through one of three keys, in priority order:
clientId— by default from$ga_client_id, the JavaScript SDK's read of Google Analytics' own_gacookie, which is the only key that joins the event to a session GA already started. When the page has no_gacookie,client_id_strategy: autoderives a stable client id from Mythic's device id instead — see Tagless GA4.appInstanceId— the app-stream equivalent. When present it suppressesclientId; sending both double-counts the event.userId— your own id, fromuser_id_property. The path for a CRM sale no browser ever saw, provided it is a value the property already knows.
Mythic's own resolved person id is deliberately not used as a userId
fallback. It is set on every enriched event, so defaulting to it would stamp an
internal UUID as your property's User-ID on essentially every delivery — a value
that never appears client-side, splitting the visitor from their own browser
sessions. Set use_canonical_user_id: true to opt in anyway.
An event with none of the three is skipped, not sent. GA4 would accept it
and attribute it to a brand-new anonymous user, inflating the property's user
count and detaching the conversion from the traffic that earned it. If GA4
deliveries are showing as skipped, the usual cause is that Google Analytics
is not on the page, so there is no _ga cookie to read.
Tagless GA4
GA4 never validates a client id — it is an opaque key whose only load-bearing property is stability: the same browser must always produce the same value, or GA4 counts one visitor as many while reporting success throughout. That makes it possible to send to GA4 with no Google tag on the site at all.
With the default client_id_strategy: auto, Mythic uses the _ga cookie when
the page has one and otherwise derives a client id from its own device id. The
derivation is a pure function of that id — never a clock read — and emits GA's
conventional <random>.<first-seen seconds> shape. Because Mythic's device id
is a UUIDv7, the second half is the id's genuine creation time rather than a
filler number. The device id is used rather than the distinct id because the
latter flips to the user id on identify(), which would hand one browser a
second client id mid-session.
Turning the tag off also removes everything the tag used to do for you, so
send_session_params and send_campaign_params are on by default: without them
GA4 makes every server event its own session and attributes all of it to
(direct)/(none).
What you give up by dropping the Google tag: Google Signals (demographics,
interests, signed-in cross-device) and cookie-based remarketing audiences
exported to Google Ads and YouTube, which are built from advertising cookies
the tag sets in the browser. Reach those audiences through Customer Match on
the google_ads destination instead. Enhanced-measurement events
(scroll, outbound_click, file_download, …) also stop firing unless you
send equivalents yourself.
One edge case in auto: a consent-gated Google tag writes no _ga cookie
until consent is granted, so a visitor can be sent a derived client id first
and a cookie-based one afterwards — two GA4 users for one browser. It is
bounded to exactly two (both values are deterministic and neither drifts) and
does not arise on a fully tagless site or one where GA is always present. Pin
client_id_strategy to mythic_device_id to avoid it entirely. An ad blocker
is not a case of this: a blocked visitor sends no client-side hits at all,
so the derived id is the only record of them rather than a duplicate.
Batching and the request quota
Google's Data Manager quota is 300 requests/min and 100,000/day per Cloud
project, and a delivery is normally one event per request — an effective
ceiling of 5 events/sec. That is irrelevant for ad conversions and immediately
fatal for tagless GA4, which carries pageview-grade volume. Set batch: true
(or a batch_size) to coalesce events into one request; events:ingest accepts
up to 2,000.
Batching is not offered on google_ads, deliberately. Data Manager is
fast-fail: "if any record fails validation for a required field, the entire
request fails, and the API does not process any of the data in that request."
One malformed row therefore takes down every other event in its batch. For GA4
pageviews that is a fair trade against a hard quota wall; for purchase
conversions it means one bad row can drop hundreds of real conversions.
The damage is contained rather than accepted. A rejected batch's
fieldViolations name the offending rows by index, so those events fail
permanently and every other event in the batch is retried — landing in a
later batch without the poison row. When no index can be parsed, nobody is
blamed and the whole batch is retried, because guessing would permanently drop a
good conversion.
If a tenant uses one OAuth client for both google_ads and
google_analytics, the two share that project's quota — unbatched GA4 volume
will starve the conversion uploads, which are the ones that cost money to
lose. Either batch GA4 or give the two destinations separate Cloud projects.
GA4's AddressInfo also accepts addressLine, city and administrativeArea,
which Google Ads ignores; the first is hashed and the other two are plaintext.
Set send_user_data: false to suppress hashed identifiers entirely.
What GA4 receives without configuration
A Google tag collects page context, engagement time and session identity automatically. Server-side, nobody does — so the adapter does, on every event:
| parameter | source | why it exists |
|---|---|---|
page_location | $current_url (full URL) | GA4 builds ALL page reporting from it; without it every page_view lands under one unnamed row |
page_title / page_referrer | $page_title / $referrer | referrer omitted when empty — "" would read as a referrer named "" |
engagement_time_msec | measured, else 1 | GA4 counts ACTIVE USERS from it; missing means views with zero users, forever |
session_id / session_number | UUIDv7 session id | GA4 converts these to its native ga_session_id/ga_session_number and computes entrances |
data_source: "mythic" | constant | one filter isolates Mythic-delivered events in any shared property — UI or BigQuery export |
Everything else on the event passes through as parameters
(send_all_properties, default on), prioritised in tiers under GA4's
25-parameter budget: your own event properties first (ecommerce, lead-gen,
experiment ids — a non-$ key is business data someone expects to report on),
curated context second (referring_domain, session_entry_*, screen sizes),
SDK internals last. Names are sanitised to GA4's rules; structured fields are
never duplicated.
A live probe sent one event with exactly 25 parameters and one with 32. The 25-param event landed fully intact. The 32-param event returned HTTP 200, received a SUCCESS ingestion verdict, showed zero errors in the Cloud Console — and landed with a random seven parameters discarded, including the one that identified the event. Not first-N, not alphabetical: arbitrary. This is why the adapter caps at 25 TOTAL deterministically and reports its own overflow: the alternative is letting Google choose which of your parameters to lose, differently each time, while every dashboard shows green.
Each toggle (send_page_params, send_engagement_time, send_all_properties,
send_source_param, send_session_params, send_campaign_params) can be
disabled independently; the defaults are what a correctly-tagged site would
produce.
Trigger rules
Every destination carries trigger_rules: an object with a non-empty rules array and an optional operator (AND or OR, default AND). Each rule requires a type. The only rule type that filters on the event name server-side is mythic_event:
{
"operator": "OR",
"rules": [
{ "type": "mythic_event", "eventName": "$pageview", "operator": "equals" }
]
}
A mythic_event rule must include:
eventName— the source event name to match (e.g.$pageview,purchase).operator—equalsornot_equalsto match on the name, or aproperty_*operator (e.g.property_equals) with apropertyto also match an event property.
Allowed type values are mythic_event, event_property, url_pattern, javascript_variable, device, query_param, cookie, time, and custom_js. Only mythic_event and event_property are evaluated server-side; the others are client-side hints and always pass server-side.
Any other type (for example event_name) is rejected with 400 invalid_config. Older examples showed { "type": "event_name", "value": "..." } — that shape is not supported. Use mythic_event with eventName + operator instead.
How trigger rules interact with event_mapping
For the ad/analytics destinations (meta_capi, google_ads, google_analytics), an event is delivered only when both conditions hold:
trigger_rulesmatch the event, and- the event name is a key in the destination's
action_config.event_mapping.
event_mapping is the effective filter for ad destinations — an event not present there is never forwarded, regardless of trigger rules. In practice, add one mythic_event rule per event you map (matching the event_mapping keys), so the trigger rules and mapping stay consistent.
Rate limiting
Each destination has an optional per-destination delivery cap, set with rate_limit_max (deliveries) over rate_limit_window_seconds (window) on create or update. Deliveries beyond the cap within the window are dropped and logged with status skipped and error rate_limited — they are not retried. When omitted, a destination defaults to 10000 deliveries per 3600 seconds (1 hour). Set an explicit rate_limit_max if your event volume warrants a different ceiling.
Credentials
Credentialed destinations (meta_capi, google_ads, google_analytics, openai_ads) read their secrets from a separate, never-readable credential store keyed by (location_id, destination_type, credential_name). Store or rotate a credential with PUT /credentials/{type}; list metadata with GET /credentials. Credential values are write-only — no endpoint ever returns them. Webhooks carry their auth inline in action_config instead.
convert_experiments is the one type where "needs a credential to deliver" and "can store a credential" differ. Delivering to Convert needs no auth at all; reading Convert's report to verify a delivery does. Store that one under credential_name: "reporting":
PUT /client/v1/destinations/credentials/convert_experiments
{
"credential_name": "reporting",
"credential_value": "{"application_id":"…","application_secret":"…"}"
}
Create the key pair in Convert under Account → API keys (v2 Reporting API). reporting is the only accepted credential_name for this type.
Delivery logs & health
GET /deliveriesreturns individual delivery attempts (newest first), filterable by destination, status, source event, and time range. It defaults to the last 7 days. Rows are lean — metadata only, no payloads.GET /{id}/deliveries/{eventUuid}returns the detail for one delivery: therequest_bodysent to the destination and the rawresponse_bodyit returned, plus redactedrequest_headers/response_headersand the Metafbtrace_id. Key it with theevent_uuidfrom a list row. Use it to answer "what did we send, and what did the destination say back?" — on successful and failed deliveries alike (confirming the accepted payload is as useful as debugging a failure). Bodies are truncated to 8 KB and the audit row is retained for 30 days. HIPAA-enabled locations omit bodies entirely (kept:fbtrace_id,response_status,error); webhook request bodies/headers are redacted for read-only (sk_) keys. Test fires (POST /{id}/test) are not persisted, so this endpoint returns404 not_foundfor a synthetictest-…event — expected, not a bug. There is exactly one detail row per (destination,event_uuid), upserted across retry attempts (attempt_countreflects the latest attempt); the per-attempt stream lives in theGET /{id}/deliverieslist.GET /healthreturns per-destination aggregates over a rolling window (default 24 hours): success rate, failure counts, latency percentiles, and acredential_okflag that flips tofalsewhen the most recent failure was a 401/403.
Testing
POST /{id}/test sends a test delivery through the ad-platform adapter using the stored credential. It is supported only for meta_capi, google_ads and google_analytics; webhooks fire on real events and return 400. Both Google destinations use Data Manager's validateOnly flag, so they verify the credential, scope, destination and payload without recording anything in the customer's account — which is also why a GA4 test sends a synthetic client id rather than reporting skipped. Meta tests are real sends and honor test_event_code.
Verifying a Convert destination
GET /{id}/verification?days=7 answers the question the delivery log cannot: did any of this actually count?
It is needed because Convert's endpoint returns 200 for payloads it silently discards, so a destination delivering nothing produces exactly the same clean delivery log as one that works. The endpoint folds your deliveries and asks Convert's own report for the same window.
The evidence is asymmetric, and the answer says so. Convert counts a goal from every source at once — its own pixel goals, its client-side adapter, and this destination — and other sources can only ever add. So a matching number comes back as consistent, never as "verified": your contribution can only be isolated when the goal belongs to this destination alone. A shortfall is the real signal, because nothing can subtract.
verdict | What it means |
|---|---|
consistent | Convert counted at least what was sent. Consistent with delivery, not proof of it. |
shortfall | Convert counted fewer than the distinct visitors delivered to the compared experience. Deliveries are being discarded. |
not_counted | Deliveries were accepted and Convert counted nothing. The run of 200s is hiding a total failure. |
nothing_sent | Nothing was delivered in the window. sent.skipped says why. |
nothing_sent_for_experience | Deliveries succeeded, but none for the experience compared — so Convert's count for it is entirely other sources. Not a fault. |
no_experiment_seen | Deliveries succeeded but named no experience, so Convert was not queried. |
experiment_inactive | The experience is not active. Convert discards conversions for a non-active experience, which explains a zero on its own. |
report_unavailable | Convert's report could not be read. No verdict rather than a guess. A mistyped experience_id lands here, not in nothing_sent_for_experience — Convert cannot report on an id it does not have. |
The comparison uses distinct visitors, not deliveries — Convert counts one conversion per visitor per goal, so comparing raw delivery counts would show a permanent phantom shortfall for any account whose customers buy twice. caveats is always populated with what the comparison cannot tell you, and sent.truncated warns when the delivery scan hit its row cap and the counts are a lower bound.
Both sides are scoped to one experience. Convert's report is per experience and goal, so the sent side counts only the deliveries that named that same experience. This is why expected is usually smaller than sent.distinct_visitors:
| Field | Scope |
|---|---|
sent.distinct_visitors | Every experience in the window. Context, not the comparison basis. |
sent.for_experience | The compared experience alone. Its distinct_visitors is expected. |
sent.by_experience | The full per-experience split, so you can see where the rest went. |
A destination running two experiments at once therefore reads expected: 3 against sent.distinct_visitors: 16 when only three of those visitors were in the experience being checked — that is correct, not a truncated count. Without experience_id the experience with the most delivered visitors is compared; pass experience_id to check another, and note that a visitor in two experiments at once counts toward both.
Two verdicts are easy to confuse when guessing at an experience_id, and they have opposite fixes. An id that does not exist in the project returns report_unavailable: Convert cannot report on it, and report_ok is evaluated before anything on the sent side, so no sent-side verdict can be reached. An id that exists but this destination never delivered to returns nothing_sent_for_experience. The first means check the id; the second means the id is fine and there is simply nothing of yours on that experiment. sent.by_experience lists the ids actually delivered to.
Convert's report lags behind a delivery, and the two counters do not land together — measured on a live account, a conversion appeared within about a minute and its revenue about three minutes later. A verification run immediately after a test fire will under-count.
Response envelope
All endpoints return a JSON envelope.
Whether the request succeeded.
Resource payload. Shape depends on the endpoint. Omitted on bare success responses (for example delete).
Present on failed requests. Contains code, message, and — for validation failures — a details array of per-field errors.
Error handling
| Status | Meaning |
|---|---|
400 | Invalid body, unsupported destination type, or configuration/validation error |
401 | Key missing or invalid |
402 | Destinations is not included in this account's plan (feature_not_in_plan) |
403 | Write attempted with a read-only secret (sk_) key |
404 | Destination not found for this location |
429 | Rate limit exceeded (120 requests per 10s per location) |
502 | Upstream data store or test service error |
503 | Analytics or testing service not configured in this environment |
Validation errors return code: invalid_config with a details array naming each offending field, so you can surface actionable messages instead of a bare 400.