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) |
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:
- Headers known to carry credentials (
authorization,access-token,developer-token,x-spm-signature,x-api-key,api-key,cookie) are replaced with***. - Query parameters and body keys known to carry credentials (
access_token,api_secret,client_secret,refresh_token,token,secret, …) are replaced with***. - 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_addressand TikTok'suser.ipare 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_agentanduser.user_agentare sent only when a server-side SDK put one in the event's$user_agentproperty.
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.