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
leadneeds an email or a phone incontext.user. With neither, the event is refused. - The extended identity set —
full_name,company,job_title, and the address fields — is read only onleadevents. Every other event keeps readingemail,phone,first_nameandlast_name. - Never send
property_id,channel,action_sourceorprovider. 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.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 simply does not receive a value or a currency.valueandcurrencyalready reach production inleadpayloads, 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"
}
useraccepts onlyemail,phone,first_name,last_name,full_name,company,job_title,street,street2,city,state,zipandcountry. One ofemailorphonemust be there.paramsaccepts onlyvalue,currencyandlead.event_timeis optional: ISO-8601 or Unix seconds, defaulting to now.- Any other key, at the top level or inside
userandparams, is refused asunknown_field. That includesproperty_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 answered400 invalid_payloadwith{"field":"Idempotency-Key","code":"idempotency_key_invalid"}. - Same key again:
200with"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 —
200with"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
- Send the real form on your site.
- Open Leads in the Console, pick the Property, and click Refresh.
- 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
- Swap
<YOUR_SECRET>for the secret you saved. - Run the command: the response is
201with"result":"created"and alead_id. - Back in Leads, click Refresh: the Lead is in the list.
- Run the same command again, with the same
Idempotency-Key: the response is200with"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
- The canonical event payload — the full Lead field reference and how a
leadbecomes a canonical event. - Verify your first event — proving ingestion at the event level.
- Install the Loader — the prerequisite for every browser path.