Documentation menu

Ad destinations

Server-side conversion forwarding. SaaS Pro Max takes the events your app already sends, adds the identifiers it captured in the browser and the person profile it built from identify, and posts a conversion to Meta, Google Ads, TikTok, LinkedIn, your own webhook, or Slack — from the server, after the fact, with a delivery log you can read.

Module key destinations. Permissions destinations.read and destinations.write. Console at /o/{org}/apps/{app}/destinations.

Why server-side

The browser pixel is the weakest link in every ad platform's measurement: blockers remove it, third-party cookies expire, and a conversion that happens on your backend (a payment that clears the next morning, a lead your sales team qualifies) never had a page to fire from.

The SaaS Pro Max browser SDK captures the ad click identifiers on the landing page — gclid, gbraid, wbraid, fbclid, _fbc, _fbp, ttclid, _ttp, li_fat_id, msclkid — and stores them on the event. When the conversion arrives, the destination adapter attaches those identifiers plus the SHA-256 of the person's normalised email and phone, and posts the conversion server-side.

Nothing here replaces the pixel; it complements it. See Deduplication below.

The flow

browser SDK  ──▶  POST /api/v1/ingest  ──▶  events table
                                        │
                                        ├─ hasActiveDestinations(appId)?  (30 s cache)
                                        │
                                        └─▶ job destinations.dispatch { eventIds }
                                                    │
                                            for each enabled destination
                                                    │
                                            environment match?
                                            mapping rule match?
                                                    │
                                            decrypt credential (vault)
                                            adapter.buildRequest(...)
                                                    │
                                            POST, 10 s timeout
                                                    │
                                            destination_deliveries row
                                            (request redacted, response summarised)

Ingest enqueues one job per accepted batch, and only when the application has at least one enabled destination. The check is cached in-process for 30 seconds, so enabling a destination takes effect within half a minute at worst; every console mutation invalidates the cache immediately on the instance that served it.

Mapping DSL

A destination forwards nothing until you map events to it. The mapping is an ordered list of rules stored as JSON:

[
  {
    "event": "purchase",
    "destinationEvent": "Purchase",
    "valueProperty": "properties.total",
    "currency": "USD",
    "contents": "properties.items",
    "conditions": [{ "property": "properties.plan", "op": "eq", "value": "pro" }]
  },
  { "event": "$pageview", "destinationEvent": "ViewContent" }
]
Field Meaning
event The SPM event name. A reserved SDK name ($pageview, $identify, …), one of your own track names, or * for every event.
destinationEvent What the platform is told. A Meta or TikTok standard event name; for Google Ads a conversion action ID; for LinkedIn a conversion rule ID; for a webhook or Slack, a label.
valueProperty Where the conversion value comes from. Defaults to the event's own value column. A rule that names a property it cannot find forwards no value rather than silently falling back.
currency Overrides the event currency. Upper-cased.
contents A property path holding an array of line items.
conditions All must hold for the rule to match.

The first rule that matches wins. Put your specific rules above your general ones — a purchase rule conditioned on plan = pro has to come before the plain purchase rule, or it will never fire.

Property paths

Path Resolves to
value, currency, name, type, path, url, title, referrer, referrer_domain, source, medium, utm_source, utm_medium, utm_campaign, utm_term, utm_content, country, region, city, browser, os, device, locale, timezone, app_version, release, distinct_id, anonymous_id, session_id, person_id, env The event column of that name
properties.<key> / property.<key> Inside the event's properties JSON, dotted paths and array indexes included (properties.items.0.price)
person.email, person.phone, person.name, person.first_name, person.last_name, person.country, person.distinct_id, person.id The person profile
person.<trait> / person.traits.<trait> A trait sent through identify
click_ids.gclid, click_ids.fbclid, … A captured ad click identifier
anything else Falls back to a dotted lookup inside properties — so plan and properties.plan mean the same thing

An event column always wins over a property of the same name. If your app sends a property called path, address it explicitly as properties.path.

Condition operators

eq, neq, contains, not_contains, gt, gte, lt, lte, in (comma-separated list), exists, not_exists.

contains and not_contains are case-insensitive. The numeric comparisons require both sides to parse as numbers; comparing a word to a number is false, never an error.

Line items

contents points at an array. Each entry may be a bare id (["SKU-1"]) or an object; the adapter reads the first key it recognises:

  • id: id, content_id, contentId, sku, product_id, productId, variant_id
  • quantity: quantity, qty, count (default 1)
  • price: price, item_price, itemPrice, unit_price, unitPrice, amount, value
  • name: name, title, content_name, contentName, product

Each platform then renames these into its own vocabulary — Meta wants item_price, TikTok wants price and content_id, GA4 wants item_id.

Deduplication with the browser pixel

Every adapter sends the SaaS Pro Max event id as the platform's deduplication key, which is the same value the browser pixel reports:

Platform Key Window
Meta event_id + event_name 48 hours
TikTok event_source_id + event + event_id 48 hours (data from a duplicate within 5 minutes is merged into the first)
LinkedIn eventId per conversion rule
Google Ads orderId when the event carries one one conversion per action

For this to work the browser pixel must send the same id. With the Meta pixel:

fbq("track", "Purchase", { value: 49, currency: "USD" }, { eventID: spmEventId });

If you cannot match the ids, run one source only. Sending both without a shared id double-counts.

Retries

A send gets one initial attempt plus three retries, at 30 s, 2 min and 8 min. A retry is a re-enqueued destinations.dispatch job with a future run_after, not a sleep inside the worker: a slot that waits is a slot that is not draining the queue, and a restart during a sleep would lose the retry.

Only failures that could plausibly succeed on a second attempt are retried: network errors, timeouts, HTTP 408, 429, and 5xx, plus TikTok's throttling code 40100. A rejected payload is recorded as failed immediately — retrying it would fail identically and only burn quota.

While a delivery is waiting for its retry its status is queued.

Delivery statuses

Status Meaning
sent The platform accepted the conversion.
failed The platform rejected it, or every retry was used.
queued A retry is scheduled.
skipped The adapter had nothing to send — no click identifier and no hashed identifier to match on, or a conversion outside the platform's lookback window.

An event that no mapping rule claims, or that arrives from an environment the destination does not forward, writes no row at all. Recording every unmatched pageview against every destination would bury the log it exists to serve.

Redaction

The delivery log is readable by anyone with destinations.read, so the stored request is redacted before it is written, in three overlapping passes:

  1. Headers known to carry credentials (authorization, access-token, developer-token, x-spm-signature, x-api-key, api-key, cookie) are replaced with ***.
  2. Query parameters and body keys known to carry credentials (access_token, api_secret, client_secret, refresh_token, token, secret, …) are replaced with ***.
  3. Every decrypted secret value is replaced wherever it literally appears — including inside a URL path, which is how a Slack incoming webhook hides.

The payload preview in the mapping editor goes through the same function, so what the browser sees is the real request with the credentials removed.

Secrets are never written to the audit ledger either: rotating credentials records the field names that were replaced, not their values.

Credentials

Each destination owns exactly one provider_credentials row, written through the vault with provider = <kind> and environment = 'destination'. Its plaintext is a JSON object holding every secret field that adapter needs. One row means one envelope, one audit event per rotation, and one decryption per dispatch batch.

Replacing credentials writes a new row and revokes the old one — never an in-place overwrite — and requires a passkey verified within the last ten minutes. So does deleting a destination.

Non-secret identifiers (pixel ID, customer ID, conversion action ID, endpoint URL) live in the destination's config column, not in the vault, so they can be shown and edited.

Environments

A destination lists the environment slugs it forwards from. An event is offered to a destination only when its environment is in that list, which is what keeps a staging test purchase out of your production ad account.

What is deliberately not sent

  • Raw IP addresses. SaaS Pro Max hashes IPs at ingest and never stores them, so Meta's client_ip_address and TikTok's user.ip are omitted rather than faked. Match quality is slightly lower than a vendor SDK that has the request in hand; the trade is deliberate.
  • Raw user agents. The events table keeps parsed browser/OS/device fields, not the header. client_user_agent and user.user_agent are sent only when a server-side SDK put one in the event's $user_agent property.

Jobs

Kind Payload What it does
destinations.dispatch { eventIds, appId?, destinationId?, attempt? } Loads the events and persons, evaluates mappings, sends, logs. Enqueued by ingest and by its own retries.
destinations.retry { deliveryId } Re-sends one logged delivery.
destinations.test { destinationId } Sends the adapter's synthetic event.

None of them throws on a delivery failure: a rejected conversion is a recorded delivery, not a failed job. Letting the worker's own retry ladder fire as well would re-send the events in the batch that did succeed.

Setup guides

Transformations and templates

Use Transformations on a destination to prepare its outgoing events. Steps run in order before event mapping. Each destination receives its own copy; stored analytics and person profiles are unchanged.

Step Behavior
Rename event Changes the event name seen by subsequent steps and mapping rules.
Rename property Moves one top-level property. An existing target is preserved unless replacement is enabled.
Remove property Removes one top-level property. Removing $user_agent also clears the adapter's user-agent field.
Add or replace property Adds a text, numeric, boolean or null constant. Existing values, including false/null, are preserved by default. Constants are ordinary configuration: keep credentials in the vault.
Filter events Keeps or drops events matching a condition. A filtered event is logged with its stopping step and is not sent.

Preview transformations runs a supplied sample without saving or sending anything. It shows each step's result and the outgoing event. Preview payload in Event mapping applies the saved pipeline before building the adapter request. Live payloads and delivery request/response details require people.pii.read; otherwise the preview uses sample data.

Pipelines use { "version": 1, "steps": [...] }. At most 20 steps run, with 256 KiB and 200 top-level keys for event properties. Conditions may reference name, type, value, currency, path, source, medium, env, or properties.<key>. Transformations cannot read person profiles, change identity/tenant fields, run code, fetch data, or access credentials. Reserved object keys are rejected. A pipeline that cannot run fails closed, with no delivery or automatic retry.

Saving checks the current pipeline revision to prevent overwriting another editor. Each delivery records the revision used. Retries use the latest saved configuration and retain the event's original deduplication ID. The Send test event control checks adapter connectivity using its synthetic test request; use transformation preview to verify pipeline behavior.

Save as reusable template copies the current saved mapping and the transformation steps in the editor. Templates belong to the selected application environment and use definition version 1 / adapter contract version 1. They contain no endpoint configuration, credential references or secrets. New destination → Your templates creates an independent copy, still disabled until enabled explicitly. Delete a template without changing destinations created from it. At most 100 templates per environment; create a new snapshot to revise a template.

Management API:

  • GET /apps/{app}/destinations/templates?env=production: bundled starters and saved templates.
  • POST /apps/{app}/destinations/templates?env=production: save { name, description?, definition }.
  • DELETE /apps/{app}/destinations/templates/{id}?env=production: delete a saved template.
  • POST /apps/{app}/destinations/{id}/pipeline: save { expectedRevision, pipeline } for all environments enabled on that destination.

Outbound deliveries retain the 10-second timeout, accept HTTPS without embedded URL credentials, refuse redirects, cap request bodies at 1 MiB and response reads at 256 KiB. Existing network egress policy must restrict access to internal services; these size and protocol checks do not perform DNS isolation.

What the assistant can do here

Ask reads the destinations an application has, their delivery statuses and the transform templates behind them, so "which conversions failed to reach Meta yesterday, and why?" is a question rather than an investigation.

Changes are proposed, never applied on the model's word. A transform template or a pipeline change waits for your approval with the before and after shown. Sending a test event to a live destination reaches a third party, so it is treated as an external action: the assistant can only request it, and it goes through the organization's own approval queue. Credentials stay in the vault — no tool returns one, and the assistant has no way to read one.