The canonical event payload
SupremeTracking accepts events from different sources, including browser tags and webhooks. Each source is translated into the same canonical Supreme Event Payload before the Node processes it.
That canonical-first rule keeps shared pipeline code generic. Source translators handle inbound differences, and each destination integration maps the canonical event to its own platform format.
A representative canonical event
The exact optional fields depend on the source and available context, but a normalized event has this shape:
{
"event_id": "evt_example_123",
"event_name": "purchase",
"event_time": 1730000000,
"channel": "webevents",
"action_source": "browser",
"provider": "direct",
"property_id": 1,
"source": "browser",
"source_platform": "direct",
"context": {
"stuid": "0123456789abcdef0123456789abcdef",
"session_id": "1.1730000000.0123456789abcdef",
"page_url": "https://shop.example.com/thank-you",
"page_title": "Order complete",
"referrer": "https://shop.example.com/checkout",
"utm": {},
"tracking_ids": {}
},
"user": {
"email": "buyer@example.com",
"phone": null
},
"params": {
"transaction_id": "order_123",
"value": 99.9,
"currency": "USD",
"items": []
}
}
This is the Node's normalized representation, not a raw request body to copy into the ingestion endpoint.
Core fields
event_ididentifies the event and supports deduplication.event_nameis the canonical action, such aspage_view,lead,add_to_cart, orpurchase.event_timeis normalized to Unix seconds.channelidentifies the ingestion family: web events, webhooks, internal events, or imports.action_sourcedescribes where the action occurred, such as browser or server.provideridentifies the normalized provider associated with the source.property_idassigns the event to an active Node Property. There is no fallback: an event that does not resolve to a valid, active Property is rejected during validation. It is stamped by the server — from the loader's?pid=in the browser, from the credential on a server-to-server write — never read from the payload.sourceandsource_platformretain useful origin context.contextcarries page, session, first-party identity, attribution, and request context when available.usercontains normalized user fields. Email casing, phone formatting, and aliases are normalized by the Node.paramscontains event-specific values such as transaction ID, value, currency, status, and items. On alead, the Lead's own fields live inparams.lead.
Do not add platform-specific destination logic to this shared shape. Meta, GA4, Google Ads, CRM, and other handlers own their canonical-to-platform mapping inside their modules.
Lead events
A lead is the one canonical event that carries a sales-pipeline block. Four doors produce it — a hand-written window.supremeSend call, the Google Tag Manager template, the Web Events skill snippet, and the Leads API — and all four converge on the same canonical event. There is no per-door variant of the shape below.
The canonical Lead block
event_name: "lead"
user: identity — email, phone, first_name, last_name, full_name, company, job_title,
street, street2, city, state, zip, country (normalized by the Node)
params: {
value?: number ≥ 0 (major units, e.g. 150.5)
currency?: "BRL" — ISO-4217, 3 uppercase letters; required when value is sent
lead?: {
status?: "new" | "contacted" | "qualified" | "won" | "lost"
source?: string ^[a-z0-9][a-z0-9_.-]{0,63}$
source_lead_id?: string 1–191 characters, no control character
custom_fields?: flat object (rules below)
}
}
From the browser — by hand, from the tag, or from the skill — the wire looks like this:
window.supremeSend({
payload: {
events: [{ name: 'lead', id: '<event id ≤ 50 bytes UTF-8, optional>', data: { params: {
value: 150.5, currency: 'BRL',
lead: { status: 'qualified', source: 'landing-page', source_lead_id: 'form-2026-0001',
custom_fields: { plan_interest: 'pro', employees: 12, newsletter: true } }
} } }],
context: { user: { email: 'maria@example.com', phone: '+5511987654321',
first_name: 'Maria', last_name: 'Silva',
company: 'Acme', job_title: 'Head of Growth' } }
}
});
Identity and Lead fields are two different blocks
Identity goes in context.user on the way in, and appears as user on the canonical event. The Lead's own fields go in params.lead.
It is params.lead and not params.status because params.status already means a purchase's payment status. A Lead's pipeline status needs its own namespace, not a second meaning for an existing key.
For lead events the Node reads the full identity set from context.user: email, phone, first_name, last_name, full_name, company, job_title, street, street2, city, state, zip, and country. For every other event it keeps reading only email, phone, first_name, and last_name — so a payload that is not a lead produces exactly the canonical event it produced before.
A lead requires an email or a phone. With neither, the event is refused.
Normalization is the Node's own: email and the name fields are lowercased, and a phone is reduced to digits while a leading + is kept, so +55 (11) 98765-4321 becomes +5511987654321. The remaining identity fields are trimmed, not lowercased.
source_lead_id is a reference into the system the lead came from. It never substitutes the Node's own identifier for that person, and it never becomes external_id on a destination payload.
status only changes the Lead row when it comes in the payload — a capture that omits it never resets it. The default new applies only when the row is created.
custom_fields
params.lead.custom_fields carries the fields that are yours rather than the product's:
- A JSON object, never a list. Keys match
^[a-z][a-z0-9_]{0,63}$, and there can be at most 50 of them. - Values are scalars only: string, integer, finite float, boolean, or
null. No arrays, no nested objects. - Size: the object encoded as JSON with unescaped Unicode and slashes must be at most 16384 bytes.
- Reserved keys, refused if used:
email,phone,first_name,last_name,full_name,company,job_title,street,street2,city,state,zip,country,gender,birth_date,external_id,lead_source,ip_address,user_agent,lead_id,status,source,source_lead_id,value,currency,property_id,stuid,event_id,event_name,event_time,channel,action_source,provider,source_platform,metadata,context,user,params,attribution,utm_source,utm_medium,utm_campaign,utm_content,utm_term,gclid,gbraid,wbraid,gad_source,fbclid,fbc,fbp,msclkid,ttclid,rdt_cid,srsltid,ga_client_id,ga_session_id,ctwa_clid,delivery,session_id,page_url,referrer,created_at,updated_at,first_seen_at,last_seen_at,is_sandbox.
On a later capture the object is merged, not replaced: a key present in the new capture overwrites, a key absent from it is preserved, and a key sent with null removes it. The merged result obeys both limits as well — if it would not, the whole capture is refused, leaving neither an event nor a row.
Error codes
| Code | When |
|---|---|
lead_block_not_object |
params.lead present and not an object |
lead_status_invalid |
status outside the allowed set |
lead_source_invalid |
source fails the regex |
source_lead_id_invalid |
empty, over 191 characters, or containing a control character |
custom_fields_not_object |
custom_fields present and not an object |
custom_field_key_invalid |
a key fails the regex |
custom_field_key_reserved |
a key is reserved |
custom_field_value_not_scalar |
a value is an array or object (or a non-finite float) |
custom_fields_too_many |
more than 50 keys (in the capture or in the merged result) |
custom_fields_too_large |
more than 16384 bytes (in the capture or in the merged result) |
value_invalid |
value is not numeric, is negative, or is not finite |
currency_invalid |
currency does not match ^[A-Z]{3}$ |
currency_required |
value present without currency |
identity_required |
neither email nor phone |
event_id_too_long |
event id over 50 UTF-8 bytes |
Severity by channel
Two different rules, for a reason:
- An invalid
params.leadblock — anylead_*,source_*orcustom_*code above — refuses the whole event, on every channel. Nothing legacy sends that block, so nothing breaks by being strict. - An invalid
valueorcurrencyis refused by the Leads API and by the surfaces (the Google Tag Manager template, the Web Events skill). On the Node's browser path the event goes through as it does today, and the Lead row simply does not receive a value or a currency.valueandcurrencyalready reach production inleadpayloads, so they cannot start dropping events.
Browser and API parity
The same content produces the same canonical event and the same final Lead state through either door. What differs is only provenance, and it differs by construction: through the Leads API the event arrives with channel: "webhooks", action_source: "server", provider: "leads_api" and source_platform: "leads_api", and its event_id is derived from the Idempotency-Key header when one is sent.
A Lead is one row per person per Property. The identity — name, email, phone, company, job title, address — does not live in that row: it stays with the identities and is read by a join.
Stamped by the server, never by the payload
These are never taken from a Lead payload:
property_id— from the loader's?pid=in the browser, from the credential on the Leads API. Aproperty_idin the API body is refused asunknown_field.channel,action_sourceandprovider— assigned by the ingestion lane that received the event.lead_idandstuid— minted and resolved by the Node.
What is ingested vs. what is stored
Everything above describes the payload the Node receives. The shape it stores and reads back is a different one. Since 3.7.0, the fields that used to sit duplicated inside custom_payload live in columns on the event row and in tables of their own. The canonical ingestion event did not change — the storage did.
If you only use the Console, this distinction asks nothing of you. It matters when you read the database from outside: your own query, an external integration, an export.
Where each part lives now
| In the ingestion payload | Where it is stored since 3.7.0 |
|---|---|
context.page_url, context.user_agent, context.ip_address |
columns on the event row |
context.utm.* |
the utm_source, utm_medium, utm_campaign, utm_term, utm_content columns |
channel, action_source, provider, source_platform |
columns on the event row |
| campaign, adset, ad, placement, network, and campaign type | the campaign_id, adset_id, ad_id, placement, network, campaign_type columns |
the channel_* set and attribution_platform |
columns on the event row |
context.tracking_ids — among them gclid, gbraid, wbraid, gad_source, fbclid, fbp, fbc, ga_client_id, ga_session_id, ttclid, and msclkid |
the attribution_identifiers table |
user |
the identities table |
params.lead (lead events) |
the Property's Lead row — one per person — with the identity still in identities |
| a purchase's parameters | purchases.platform_data |
| the session | the sessions table, plus the session columns on the event row |
params, page.title, page.referrer, screen, ingest_host, metadata |
still in custom_payload |
context.cookies (the raw browser jar) |
not persisted — see below |
custom_payload is now a residue, not the whole event
Before 3.7.0, reading custom_payload was reading the event. Today that column holds only what has nowhere else to live: params, page.title, page.referrer, screen, ingest_host, and metadata. When nothing is left, it is NULL.
Anything that queries custom_payload directly gets only that last row of the table above — not the event. A purchase's parameters are not even there: they live in purchases.platform_data, the authoritative copy, which tracks status changes where the copy frozen into the event did not.
If your integration or your report reads custom_payload expecting the whole event, it is the read that has to change source.
The Console reassembles the event on read
The event detail's Raw JSON tab does not print custom_payload. It shows the event reassembled from every source in the table above — the row's columns, attribution_identifiers, identities, purchases, and the session. That is why it shows more than custom_payload contains, and why it is the reference for what the Node kept about that event.
What survives from the cookie jar
The raw cookie jar is transit data: it arrives at bootstrap, serves the pipeline, and is written nowhere. Before it evaporates, whatever carried identity is distilled and persisted:
_fbp, read from the cookie of the same name._fbc, read from the cookie when it exists and synthesised from the URL's?fbclid=parameter when it does not — so some stored values were never a cookie at all.ga_client_idandga_session_id, parsed out of the_gaand_ga_*cookies.stuid, read from the Node's own cookie.
Cookies that map to no known identifier — third-party ones, your own site's — are persisted nowhere. attribution_identifiers is a register of identifiers that sometimes originate from cookies, not a mirror of the browser's jar.
Keeping the identifier instead of the cookie is a design decision: the Node keeps what attribution needs and does not accumulate the rest of your visitor's browser.
The five pipeline stages
After translation, EventPipeline::process() runs these stages in order:
- Filter applies rejection rules before further processing.
- Validation checks whether the canonical event is acceptable.
- Enrichment resolves and adds locally available identity and context.
- Persistence stores the resulting event on the Node.
- Forwarding passes the canonical event to each active integration handler.
Because Persistence runs before Forwarding, an event can be visible in the Console even when a destination later rejects or fails to receive it. Use the Node logs and the destination's own diagnostics to investigate that second half.
From GTM to a destination
The current GTM template builds a browser envelope and calls window.supremeSend. The Node translates that input into the canonical shape above. Learn how to configure the source in Connect Google Tag Manager.
Forwarding modules then read the canonical event and build destination payloads. Learn how modules are separated in Integrations overview.
To inspect the result on your own Node, verify your first event.