Documentation Loading events

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_id identifies the event and supports deduplication.
  • event_name is the canonical action, such as page_view, lead, add_to_cart, or purchase.
  • event_time is normalized to Unix seconds.
  • channel identifies the ingestion family: web events, webhooks, internal events, or imports.
  • action_source describes where the action occurred, such as browser or server.
  • provider identifies the normalized provider associated with the source.
  • property_id assigns 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.
  • source and source_platform retain useful origin context.
  • context carries page, session, first-party identity, attribution, and request context when available.
  • user contains normalized user fields. Email casing, phone formatting, and aliases are normalized by the Node.
  • params contains event-specific values such as transaction ID, value, currency, status, and items. On a lead, the Lead's own fields live in params.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.lead block — any lead_*, source_* or custom_* code above — refuses the whole event, on every channel. Nothing legacy sends that block, so nothing breaks by being strict.
  • An invalid value or currency is 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. value and currency already reach production in lead payloads, 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. A property_id in the API body is refused as unknown_field.
  • channel, action_source and provider — assigned by the ingestion lane that received the event.
  • lead_id and stuid — 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_id and ga_session_id, parsed out of the _ga and _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:

  1. Filter applies rejection rules before further processing.
  2. Validation checks whether the canonical event is acceptable.
  3. Enrichment resolves and adds locally available identity and context.
  4. Persistence stores the resulting event on the Node.
  5. 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.