OverviewIntroduction

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.

Typeaction_typeWhat it doesCredential
WebhookwebhookPOSTs the event to an http(s) URL you controlInline in action_config (headers / body template)
Meta Conversions APImeta_capiSends server-side conversions to a Meta pixelStored separately (meta_capi)
Google Adsgoogle_adsUploads conversions to a Google Ads customer via the Data Manager APIStored separately (google_ads)
Google Analytics 4google_analyticsSends events to a GA4 property via the same Data Manager APIStored separately (google_analytics)
OpenAI (ChatGPT) Adsopenai_adsSends server-side conversions to an OpenAI Ads pixelStored separately (openai_ads)
Convert.com experimentsconvert_experimentsCredits an off-browser conversion to the A/B variation the visitor was inNone 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.

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 PROCESSING to SUCCESS. Checking GA4 seconds after a delivery shows nothing, correctly.
  • DebugView and Realtime are not valid tests. There is no debug_mode equivalent 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.comcloudysanfrancisco@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:

FieldWhat it adds
user_propertiescustomerType (NEW/RETURNING/REENGAGED) and customerValueBucket. customerType is what Google Ads' new-customer acquisition goal bids against.
cart_dataLine items, unit prices, quantities, coupons, Merchant Center ids — Shopping and value-based bidding signals.
custom_variablesConversion custom variables, for value rules and segmentation.
conversion_count_propertyQuantity for counting-based conversions.
last_updated_propertyRestatements — re-upload a refunded or repriced order under the same transactionId.
event_locationStore 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:

  1. clientId — by default from $ga_client_id, the JavaScript SDK's read of Google Analytics' own _ga cookie, which is the only key that joins the event to a session GA already started. When the page has no _ga cookie, client_id_strategy: auto derives a stable client id from Mythic's device id instead — see Tagless GA4.
  2. appInstanceId — the app-stream equivalent. When present it suppresses clientId; sending both double-counts the event.
  3. userId — your own id, from user_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:

parametersourcewhy 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 / $referrerreferrer omitted when empty — "" would read as a referrer named ""
engagement_time_msecmeasured, else 1GA4 counts ACTIVE USERS from it; missing means views with zero users, forever
session_id / session_numberUUIDv7 session idGA4 converts these to its native ga_session_id/ga_session_number and computes entrances
data_source: "mythic"constantone 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).
  • operatorequals or not_equals to match on the name, or a property_* operator (e.g. property_equals) with a property to 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:

  1. trigger_rules match the event, and
  2. 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 /deliveries returns 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: the request_body sent to the destination and the raw response_body it returned, plus redacted request_headers/response_headers and the Meta fbtrace_id. Key it with the event_uuid from 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 returns 404 not_found for a synthetic test-… event — expected, not a bug. There is exactly one detail row per (destination, event_uuid), upserted across retry attempts (attempt_count reflects the latest attempt); the per-attempt stream lives in the GET /{id}/deliveries list.
  • GET /health returns per-destination aggregates over a rolling window (default 24 hours): success rate, failure counts, latency percentiles, and a credential_ok flag that flips to false when 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.

verdictWhat it means
consistentConvert counted at least what was sent. Consistent with delivery, not proof of it.
shortfallConvert counted fewer than the distinct visitors delivered to the compared experience. Deliveries are being discarded.
not_countedDeliveries were accepted and Convert counted nothing. The run of 200s is hiding a total failure.
nothing_sentNothing was delivered in the window. sent.skipped says why.
nothing_sent_for_experienceDeliveries succeeded, but none for the experience compared — so Convert's count for it is entirely other sources. Not a fault.
no_experiment_seenDeliveries succeeded but named no experience, so Convert was not queried.
experiment_inactiveThe experience is not active. Convert discards conversions for a non-active experience, which explains a zero on its own.
report_unavailableConvert'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:

FieldScope
sent.distinct_visitorsEvery experience in the window. Context, not the comparison basis.
sent.for_experienceThe compared experience alone. Its distinct_visitors is expected.
sent.by_experienceThe 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.

successboolean
Required

Whether the request succeeded.

dataobject|array

Resource payload. Shape depends on the endpoint. Omitted on bare success responses (for example delete).

errorobject

Present on failed requests. Contains code, message, and — for validation failures — a details array of per-field errors.

Error handling

StatusMeaning
400Invalid body, unsupported destination type, or configuration/validation error
401Key missing or invalid
402Destinations is not included in this account's plan (feature_not_in_plan)
403Write attempted with a read-only secret (sk_) key
404Destination not found for this location
429Rate limit exceeded (120 requests per 10s per location)
502Upstream data store or test service error
503Analytics 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.