Documentation Loading events

Capture Leads and use the Leads API

A Lead is a person who left their contact on one of your sites, stored on your own Node. This article goes from the Leads power-up turned off to a Lead captured by a form and updated by your server, and shows how to prove each step.

It is written for two readers at once: the operator who configures the Node and the Console, and the developer who wires a form or a CRM. You do not need to know what a Property or a Node identifier is before you start.

What a Lead is on your Node

  • One row per person per Property. A later capture of the same person updates the same Lead: the capture count goes up and the last-seen time moves forward. The same person on two Properties is two Leads.
  • First touch is written once. The UTMs, the landing page and the referrer domain of the first capture are recorded when the Lead is created, and never rewritten afterwards. Only the domain of the referrer is kept, never the full referring URL.
  • Identity does not live in the Lead. Name, email, phone, company, job title and address stay with the person's identity on your Node and are read by a join. The Lead itself carries the pipeline fields.
  • Sandbox stays separate. A capture made while the Node is in sandbox mode never alters a real Lead, and a sandbox Lead never appears in the list.
  • It is yours. Leads live on your Node. The browser sends to your Node through the loader, and your server posts straight to your Node. Supreme's servers never see a Lead, an email or a phone number.

Turn the Leads power-up on

Open Settings at the foot of the Console sidebar, go to the System Core tab, and find the Leads row under Power-ups, just below the license card.

The row shows one of three states:

Badge What it means What the row says
Active Leads is on your plan "Leads is active on your plan."
Available the plan could not be confirmed right now, and Leads stays usable "We couldn't confirm your plan right now; Leads stays available."
Unavailable Leads is not on your plan "Leads is not part of your plan.", with See plans if you own the Node and Ask the Node owner if you do not

Available is not a degraded Active. It means the Console could not read your plan at that moment, and chose to keep Leads usable rather than lock you out of your own data.

If the Leads entry is missing from the Console navigation, the Node itself does not answer for Leads yet. The Console says so — "This Node does not have Leads yet." — and offers Open Node Health, where you update the Node. The rest of the Console keeps working while you do.

If Leads leaves your plan

Your leads, sources and credentials stay intact and readable. New configuration is blocked until Leads is back on your plan.

That is what happens, and it is all that happens. Nothing is deleted, paused or revoked, and your Node is never switched off. Your Leads stay listed and readable in the Console, and a credential you already issued keeps being accepted by your Node. What stops is new configuration: the capture-path setup is no longer offered, so you cannot generate or rotate a credential until Leads is back on your plan.

Capture from the browser: one contract, three paths

Three paths produce the same canonical lead event, so the field shape, the rules and the error codes are identical whichever you pick. The full field reference lives in the Lead events section of The canonical event payload.

All three need the loader on the page, carrying the id of the Property the Lead belongs to. See Install the Loader.

Three rules hold for all three paths:

  • A lead needs an email or a phone in context.user. With neither, the event is refused.
  • The extended identity set — full_name, company, job_title, and the address fields — is read only on lead events. Every other event keeps reading email, phone, first_name and last_name.
  • Never send property_id, channel, action_source or provider. Those belong to the Node: the Property comes from the loader's ?pid=, and the rest is stamped by the lane that received the event.

window.supremeSend by hand

Call it when your form reports success, not when the submit button is clicked.

window.supremeSend({
  payload: {
    events: [{ name: 'lead', 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' } }
  }
});

Everything inside params.lead is optional. An event id of your own can go in id, next to name, up to 50 UTF-8 bytes.

A minimal version of this snippet is ready to copy in the Console, under Leads → How to capture Leads → Browser/GTM.

The Google Tag Manager template

Pick Lead as the event. The tag then shows a Lead group with Lead Status, Lead Source, Source Lead ID, Lead Value and Lead Currency, plus a Custom Fields table of Key and Value rows. The identity fields gained Full Name, Company, Job Title and Street Address 2.

The tag refuses, before sending, every Lead the Node would refuse, and prints the refusal code in the Preview debug console. You see the problem in GTM instead of discovering a missing Lead later.

See Connect Google Tag Manager.

The Web Events skill

From 1.3.0 the skill ships a Contact Form Lead pattern that wires a form submission to a lead event, custom_fields included, and a validator that refuses a Lead with the same codes the Node uses.

See Install the Web Events skill.

Canonical fields and custom_fields

The product's own fields are named; everything else is yours and goes in custom_fields.

Field Rule If you leave it out
params.lead.status one of new, contacted, qualified, won, lost new on creation. An existing Lead keeps the status it has: a capture that omits it never resets it
params.lead.source string matching ^[a-z0-9][a-z0-9_.-]{0,63}$ the Node fills the channel's default when the Lead is created — website from the browser, api from the Leads API — and never rewrites it afterwards
params.lead.source_lead_id 1–191 characters, no control character stays empty. A later capture fills it only while it is still empty; a different value is ignored
params.lead.custom_fields flat object, rules below the fields already stored stay as they are
params.value number ≥ 0, in major units such as 150.5 the Lead carries no value
params.currency ISO-4217 code, three uppercase letters; required when value is sent —

source_lead_id is a reference into the system the lead came from. It never substitutes the identifier your Node has for that person, and it never becomes external_id on a payload sent to an ad platform.

custom_fields

  • 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 simply does not receive a value or a currency. value and currency already reach production in lead payloads, so they cannot start dropping events.

The Leads API: your server to your Node

Use it when the Lead is born outside the browser — a CRM, a back office, a phone call typed into a form by your team — or when you need to update a Lead your site already captured. The shape is the canonical one: the same content through either door produces the same Lead.

The credential

One credential per Property. In the Console, open Leads, pick the Property in Property, then How to capture Leads → the API tab → Generate credential.

  • The secret is shown once. The Console says so: "Save it now: this secret will not be shown again." Put it in your server's vault before you leave the page. It is stored on your Node only as a hash — there is no recovery and no second copy.
  • Server-side only. Never put it in browser code, a log, a URL, a diagnostic, a screenshot or a repository.
  • The Master Key does not work here. Presented on this lane it is answered 401, exactly like a wrong secret.
  • The card then shows Endpoint URL, Public ID, the last four characters of the secret under Secret, and Created, Rotated, Last used, Requests and Last result.
  • Rotate secret issues a new secret and asks you to confirm first: "The previous credential stops working immediately. Update the secret on your server." There is no grace window. The Endpoint URL and the Public ID do not change.

Copy the Endpoint URL the Console shows. Do not assemble it by hand.

The request

POST <the Endpoint URL the Console shows>
Authorization: Bearer <YOUR_SECRET>
Content-Type: application/json
Idempotency-Key: lead-0001

Authorization and Content-Type are required; Idempotency-Key is optional. A request that is not JSON is answered 415, and a body over 65536 bytes is answered 413 — both before the credential is even read, so a correct secret does not rescue a malformed request.

The body:

{
  "user": { "email": "maria@example.com", "phone": "+5511987654321",
            "first_name": "Maria", "last_name": "Silva",
            "company": "Acme", "job_title": "Head of Growth" },
  "params": { "value": 150.5, "currency": "BRL",
              "lead": { "status": "qualified", "source": "crm", "source_lead_id": "4821",
                        "custom_fields": { "plan_interest": "pro" } } },
  "event_time": "2026-09-23T12:00:00Z"
}
  • user accepts only email, phone, first_name, last_name, full_name, company, job_title, street, street2, city, state, zip and country. One of email or phone must be there.
  • params accepts only value, currency and lead.
  • event_time is optional: ISO-8601 or Unix seconds, defaulting to now.
  • Any other key, at the top level or inside user and params, is refused as unknown_field. That includes property_id: the Property comes from the credential, never from the body.

The responses

HTTP Body When
201 {"ok":true,"result":"created","event_id":"…","lead":LeadWrite} the Lead was created
200 {"ok":true,"result":"updated","event_id":"…","lead":LeadWrite} a new event updated an existing Lead
200 {"ok":true,"result":"duplicate","event_id":"…","lead":LeadWrite} a replay of the same Idempotency-Key; no new event
400 {"ok":false,"error":"invalid_payload","errors":[{"field":"params.lead.custom_fields.foo","code":"custom_field_value_not_scalar"}]} any rule above was broken — nothing is stored
401 {"ok":false,"error":"unauthorized"} no header, wrong secret, unknown public id, a secret belonging to another Property, the Master Key, or an inactive Property
405 {"ok":false,"error":"method_not_allowed"} anything but POST
413 {"ok":false,"error":"payload_too_large"} body over 65536 bytes
415 {"ok":false,"error":"unsupported_media_type"} the request is not JSON
422 {"ok":false,"error":"custom_fields_merge_overflow"} the merge would exceed 50 keys or 16384 bytes — nothing is stored

LeadWrite carries lead_id, status, source, source_lead_id, value_cents, currency, custom_fields, first_seen_at, last_seen_at and capture_count. No identity is echoed back, and no response reveals whether another Property, Lead or credential exists.

The 401 body and status are identical in all of those cases on purpose: an unknown public id is never answered with a 404, so the lane cannot be used to discover which credentials exist.

Besides the codes in the table above, errors[] can carry three that only this lane produces:

Code When
unknown_field a key outside the contract, including property_id in the body
invalid_type the body, user or params is the wrong type, a user field is not a string, or event_time cannot be read
idempotency_key_invalid the Idempotency-Key header fails its format

An unexpected failure after validation is answered 500 with {"ok":false,"error":"internal_error"}. Nothing is written half-way.

The call

curl -X POST '<the Endpoint URL the Console shows>' \
  -H 'Authorization: Bearer <YOUR_SECRET>' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: lead-0001' \
  -d '{"user":{"email":"maria@example.com","first_name":"Maria"},"params":{"lead":{"status":"new","source":"crm"}}}'

The Console shows this same command under Example request, with your own endpoint URL already filled in.

What the Console tells you about the lane

Every authenticated request moves three fields on the credential card: Requests, Last used and Last result. Last result reads one of Lead created, Lead updated, Replay ignored (same Idempotency-Key), Payload rejected, Credential rejected, or No request yet.

That is the fastest way to tell "my server never called" from "my server called and was refused".

Idempotency

A retry after a timeout should not produce a second capture. That is what Idempotency-Key is for.

  • Format: 1 to 50 characters from A–Z, a–z, 0–9, ., _, : and -. An invalid key is answered 400 invalid_payload with {"field":"Idempotency-Key","code":"idempotency_key_invalid"}.
  • Same key again: 200 with "result":"duplicate". No new event, no second Lead, and the capture count does not move.
  • No key at all: every call is a new event on the same Lead — 200 with "result":"updated".

Derive the key from the lead's id in the source system plus a version of the change, for example crm-4821:v3. A key reused across two genuinely different updates makes the second one answer duplicate and silently do nothing.

Prove the capture worked

From the browser

  1. Send the real form on your site.
  2. Open Leads in the Console, pick the Property, and click Refresh.
  3. The row appears. Open it to see Identity, Lead data, Acquisition (first touch), Custom fields and Recent captures.

To look at the event itself rather than the Lead, follow Verify your first event.

This proves that the page emitted the event, that your Node accepted and stored it, and that the Console read it back from that Node. It does not prove that Meta, GA4 or any other destination accepted a forwarded event — check that in the destination's own view.

From the API

  1. Swap <YOUR_SECRET> for the secret you saved.
  2. Run the command: the response is 201 with "result":"created" and a lead_id.
  3. Back in Leads, click Refresh: the Lead is in the list.
  4. Run the same command again, with the same Idempotency-Key: the response is 200 with "result":"duplicate", and Captures has not moved.

The Console lists these same steps under How to prove it in the API tab.

Troubleshooting without exposing PII

Before anything else: never paste a real secret, email or phone number into a ticket, a screenshot or a chat. Use the example values from this page instead. The Console never shows a secret twice, and no Leads API response echoes identity back.

Symptom Likely cause What to do
The Lead never appears the capture carried neither email nor phone add one of them to context.user or to user; without either, the event is refused
The Lead never appears the params.lead block was invalid, so the whole event was refused read the code: in GTM it is in the Preview debug console, from the API it is in errors[]
The Lead appears under the wrong site the loader on the page carries another Property's id fix the ?pid= on that page — see Install the Loader
The Lead appears nowhere, and nothing is refused the Node is in sandbox mode, and sandbox Leads are never listed turn sandbox off and capture again
401 from the API wrong secret, a secret that has been rotated, a secret from another Property, or the Master Key the response is deliberately identical in all four cases. Rotate the secret in the Console and update your server
400 with errors[] a field broke a rule match each code against the tables above; nothing was stored, so fix and resend
422 custom_fields_merge_overflow the merge would exceed 50 keys or 16384 bytes send the keys you no longer need with null to remove them, or send fewer keys
415 or 413 the request is not JSON, or the body is over 65536 bytes fix the header or the payload; the secret was never the problem
A Lead captured from the browser has no value currency was missing or invalid the browser path lets the event through and drops only the value and currency. Send a valid three-letter code with the value
duplicate when you expected a change the Idempotency-Key was reused use a key that changes with the content, such as the source id plus a version
Leads is missing from the navigation the Node does not answer for Leads yet open Node Health and update the Node
Generate credential and Rotate secret are disabled Leads is not on the plan right now your Leads and the credential you already have are untouched; see the power-up section above

Next