Webhooks Subcontext sends

Receive each new lead and other lead events as signed JSON, check the signature, and push leads into your CRM's own API.

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#

  1. Open Integrations, External systems, press Connect and choose Webhook endpoint.
  2. 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.
  3. Press Connect. The signing secret is shown once. Copy it for your receiver.
  4. 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#

Code
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):

json
{
  "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:

Code
t=1791797415,v1=5f8a2c0d9b...e1
  • t is when it was signed, in seconds since 1970 (Unix time).
  • v1 is 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:

  1. 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.
  2. Split the header on , and each part on the first = to get t and v1.
  3. Refuse it if t is more than 5 minutes from your clock (this stops an old delivery being replayed).
  4. Work out the HMAC yourself and compare it to v1 with a constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest, hash_equals).
  5. Refuse it if it doesn't match, with 400, 401 or 403. 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 2xx within 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-Key you've already handled. Two different webhook actions on one workflow send the same event_id with different keys, so use the key, not the event_id, to spot repeats.
  • 429, 5xx or 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:

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:

json
{
  "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:

json
{
  "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.

Stuck, or something missing?

Get in touch and a person on the team will help.

Contact us