Subcontext can tell your systems when something happens to a lead, for example when it arrives, moves stage, is won or gets an owner. A workflow decides what to send and when, and there are two ways to send it:
| Send a webhook | Send to an integration | |
|---|---|---|
| The request | A fixed, signed JSON envelope, POSTed to one address | Whatever your API expects: your method, path, body template and headers |
| Proves it's from Subcontext with | An HMAC signature you check | The credential on your custom integration (API key, bearer token, basic auth or OAuth2) |
| Best for | Your own receiver, or a tool such as Zapier or Make | Your CRM's existing API, without writing a receiver |
| Set up in the dashboard | A Webhook endpoint connection | A custom integration with an endpoint |
Both run from a workflow (CRM, Workflows): set When (for example A lead arrives), optionally limit it to one campaign, and add the action. The dashboard help guide Send every new lead to your CRM or a Google Sheet walks through it. Both need custom integrations on your plan.
Send a webhook#
Set it up#
- Open Integrations, External systems, press Connect and choose Webhook endpoint.
- Enter a Connection name and your receiver's address as the Base url
(it must be
https://). Type a Signing secret (a long random value that only you and Subcontext know), or leave it blank and Subcontext generates one. - Press Connect. The signing secret is shown once. Copy it for your receiver.
- In a workflow, add the action Send a webhook and choose the connection.
Every webhook endpoint has a signing secret. If you lose it, press Generate under New signing secret on the connection's Configure page. The new one is shown once and used from then on, so update your receiver at the same time.
Endpoints set up before the secret was required may have none. Their deliveries go out unsigned, so they're marked unsigned on External systems and can't be saved until they have a secret.
The request#
POST https://hooks.example.com/subcontext
Content-Type: application/json
Idempotency-Key: (the same on every retry of this delivery)
X-Subcontext-Signature: t=1791797415,v1=5f8a2c...e1
The request goes to the Base url exactly as you entered it. There's no other credential. The signature is how you know it's from Subcontext.
The body#
Every delivery has the same shape, whatever triggered it. This is a
lead.created (shortened):
{
"event_id": "0b6f5d1e-6c2a-4f0e-9a51-6f3e2d1c0a9b",
"event_type": "lead.created",
"trigger_name": "A lead arrives",
"occurred_at": "2026-10-12T10:30:15+01:00",
"payload": {},
"workflow_id": "5d3e9c7a-1b2f-4e8d-a6c4-9f0e1d2c3b4a",
"workflow_name": "New leads to our CRM",
"campaign_key": "spring_newsletter",
"contact_id": "c1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"organization_id": "e9f8d7c6-b5a4-4392-8170-6f5e4d3c2b1a",
"contact": {
"contact_id": "c1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"external_system_id": null,
"name": "Ada Lovelace",
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com",
"phone": "+447700900123",
"email_verified": false,
"phone_verified": true,
"consent": {
"service": { "email": "granted", "sms": "granted", "whatsapp": "unknown", "phone": "unknown", "post": "unknown" },
"first_party_marketing": { "email": "refused", "sms": "unknown", "whatsapp": "unknown", "phone": "unknown", "post": "unknown" }
},
"tps_status": "clear"
},
"lead": {
"contact_id": "c1a2b3c4-d5e6-4f70-8192-a3b4c5d6e7f8",
"external_system_id": null,
"name": "Ada Lovelace",
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com",
"phone": "+447700900123",
"consent": { "service": { "email": "granted" }, "first_party_marketing": { "email": "refused" } },
"tps_status": "clear",
"stage": "new",
"status": "open",
"score": 42,
"owner_id": null,
"owner": null,
"last_touched_at": "2026-10-12T10:30:15+01:00",
"buying_signal": null,
"follow_up": null,
"outcome_reason": null,
"priority": null,
"vulnerable": false,
"snoozed_until": null,
"archived": false,
"next_action": { "type": null, "summary": null, "due_at": null },
"fields": { "cover_amount": 250000, "smoker": false, "postcode": null }
},
"facts": { "cover_amount": "£250,000", "smoker": "No" }
}
(lead.consent has the same five channels as contact.consent; it's cut
short here.)
| Key | What it is |
|---|---|
event_id |
The lead event's unique id. |
event_type |
Which trigger fired, such as lead.created (the list is below). |
trigger_name |
The same trigger as the workflow builder names it. |
occurred_at |
When it happened, ISO 8601 with the UTC offset. |
payload |
The event's own details: from and to on stage and owner changes, the field keys on lead.facts_recorded, the changes on contact.details_changed. Often empty. |
workflow_id, workflow_name |
The workflow that sent it. |
campaign_key |
The campaign the lead arrived on, or null. |
contact_id |
Subcontext's id for the lead. Store it and send it back with outcomes, so each outcome matches its lead. |
organization_id |
Your organisation's id in Subcontext. |
contact |
The person as they are now. external_system_id is your id for them, if Subcontext has been told it. tps_status is clear, registered, not_applicable, or null if never screened. |
contact.consent |
For each purpose (service, first_party_marketing) and channel (email, sms, whatsapp, phone, post): granted, refused, withdrawn or unknown. unknown means nobody has said either way, so don't treat it as consent. |
lead |
Where the lead stands: stage (the stage's key), status (open, won, lost, dormant, disqualified, duplicate, invalid, not_interested or ineligible), score, owner (id, name, email), vulnerable, next_action and more. The pipeline keys are null if the person has no lead record yet. |
lead.fields |
Every field your firm has set up, by its key, with typed values (null when not known, a list for fields that take several values). |
facts |
Only the fields with a value, by key, as display text. |
The body describes the lead as it is when the delivery is sent, not as it was when the event happened. If a retry goes out a few minutes later, it carries the latest details.
Event types#
The workflow's When decides which events are sent. The event_type values:
event_type |
In the workflow builder |
|---|---|
lead.created |
A lead arrives |
lead.facts_recorded |
We learn something new |
lead.stage_changed |
The stage changes |
lead.owner_assigned |
An owner is assigned |
lead.status_changed |
The outcome changes |
lead.outcome_reason_set |
A reason is given for the outcome |
lead.vulnerability_flagged |
They may be vulnerable |
lead.next_action_due |
A next action falls due |
lead.first_contact_overdue |
Nobody has contacted them yet |
lead.grade_changed |
Their grade changes |
lead.tps_screened |
Their number is screened |
lead.task_completed |
A task is done |
lead.snooze_ended |
A snooze ends |
lead.manual_run |
Somebody runs it by hand |
contact.details_changed |
Contact details change |
activity.<kind> |
Anything that appears on the lead's timeline, such as a booking, a reply or a policy being bound |
New event types can be added over time. Ignore any you don't recognise instead of failing.
Check the signature#
X-Subcontext-Signature looks like this:
t=1791797415,v1=5f8a2c0d9b...e1
tis when it was signed, in seconds since 1970 (Unix time).v1is the HMAC-SHA256, in lower-case hex, of the timestamp, a full stop, and the raw request body, keyed with your signing secret:HMAC_SHA256(secret, t + "." + body).
To check it:
- Read the raw body exactly as it arrived, before any JSON parsing. Parsing and re-encoding it changes the bytes and the signature won't match.
- Split the header on
,and each part on the first=to gettandv1. - Refuse it if
tis more than 5 minutes from your clock (this stops an old delivery being replayed). - Work out the HMAC yourself and compare it to
v1with a constant-time comparison (crypto.timingSafeEqual,hmac.compare_digest,hash_equals). - Refuse it if it doesn't match, with
400,401or403. Any of these refuses only that delivery. Later ones still arrive. See How your answer is read.
Each attempt is signed afresh, so a retry has a new t and v1.
Answer, and skip repeats#
- Answer
2xxwithin 15 seconds. The body is ignored. Do the slow work afterwards. - A delivery can arrive more than once (after a timeout, for example). Skip
any
Idempotency-Keyyou've already handled. Two different webhook actions on one workflow send the sameevent_idwith different keys, so use the key, not theevent_id, to spot repeats. 429,5xxor no answer: Subcontext tries again, up to 5 attempts over about six minutes. Other statuses aren't retried. See Retries.
Example receivers#
Each of these checks the signature, skips repeats and answers straight away:
-
webhook-receiver.mjs
Node: receives Subcontext webhooks, checks the X-Subcontext-Signature header and skips repeats.
-
webhook_receiver.py
Python: receives Subcontext webhooks, checks the signature and skips repeats, with the standard library only.
-
webhook-receiver.php
PHP: receives Subcontext webhooks, checks the signature and skips repeats.
Send to an integration#
If your CRM already has an API for creating leads, you don't need a receiver. Describe the API as a custom integration (see Your API as an agent tool for the parts), add an endpoint for "create lead", and choose it in the workflow action Send to an integration.
The request follows the endpoint's settings, with the same rules as an agent
tool's (see What arrives at your endpoint).
The difference is that the template's variables come from the envelope above
instead of an agent's parameters, so you can use {{ contact.email }},
{{ lead.stage }}, {{ lead.fields.cover_amount }}, {{ event_type }} and so
on:
{
"source": "subcontext",
"subcontext_id": {{ contact_id | json }},
"name": {{ contact.name | default: "Unknown" | json }},
"email": {{ contact.email | json }},
"phone": {{ contact.phone | json }},
"stage": {{ lead.stage | json }},
"campaign": {{ campaign_key | json }}
}
A JSON POST, PUT or PATCH endpoint with no body template sends the whole envelope as the body, as Send a webhook does, without the signature header.
Each send carries an Idempotency-Key that is unique to the event and the
workflow action. It's retried in the same way as a webhook, and authenticates
with the integration's credential. If your API returns its own id for the new
record, Subcontext doesn't store it from this action. Send it back with the
API, or include our contact_id in your record, so later events and outcomes
can be matched.
Lifecycle webhooks (insurance)#
If your firm sells insurance through Subcontext, a product's Lifecycle
events (quote issued, policy bound, premium failed, claim approved and so on)
can have a Webhook action too. It uses the same Webhook endpoint
connection, the same X-Subcontext-Signature and Idempotency-Key headers and
the same retries, with a body about the policy rather than the lead:
{
"id": "3f9a1c2e-8b7d-4e6f-a5c4-b3a2918f7e6d",
"type": "policy.bound",
"occurred_at": "2026-10-12T10:30:15+01:00",
"aggregate": { "type": "policy", "id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d" },
"payload": {},
"product_id": "1e2d3c4b-5a69-4788-96a5-b4c3d2e1f0a9",
"organization_id": "e9f8d7c6-b5a4-4392-8170-6f5e4d3c2b1a",
"policy": { "...": "the policy as it is when sent" }
}
Depending on the event, the snapshot is policy, or quote with
application and customer, or application with customer. The
customer's answers to underwriting questions are never included in a webhook.
The dashboard help article Lifecycle event actions covers setting it up.