Deliver agent-sourced leads to your CRM
When a request on your site is completed (a consultation, a quote, a callback, a booking), confirmed by the person on your card or by their own agent with their go-ahead, the lead already lives in your Inception Agents Leads inbox and in the email your team receives. This recipe puts the same lead where your team actually works: the CRM, the scheduling tool, Slack, a sheet. You subscribe to signed lead.*, booking.* and call.* webhooks, verify the signature, and hand the payload to your system of record.
Two things make these different from the Revenue Moment webhooks: a lead envelope can carry the person’s contact details (only to subscriptions created for that purpose, only once the request was completed with their consent), and it carries the eleven layers the business pays for: what they typed, what they said in their own words, which tool made the request, whether the contact was verified, the qualification verdict, the value estimate, where the lead came from, how it should be routed, how the person wants to be contacted, and (later) what happened.
What you need before starting:
- A webhook subscription of kind
leadorbooking(below). Creation, signing and delivery semantics are the same as Webhooks. - A receiver: your own endpoint, a Zapier Catch Hook, or a Make custom webhook.
Subscription kinds
The kind you choose decides what the envelope carries:
| Kind | Receives | Contact block |
|---|---|---|
lead | lead.*, call.*, and booking.* | Yes — name, email, phone, decrypted at delivery |
booking | booking.* only | Never — slot, status and the reference only |
moment (default) | Revenue Moment lifecycle events | Not applicable (pseudonymous subject_ref) |
Create a lead subscription:
curl -X POST https://api.inceptionagents.com/api/v1/webhooks/subscriptions \
-H "Authorization: Bearer $INCEPTION_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
"kind": "lead",
"event_types": ["lead.captured", "lead.outcome_reported", "booking.confirmed"],
"label": "Zapier → HubSpot"
}'
The response carries signing_secret exactly once. Store it as INCEPTION_WEBHOOK_SECRET in the receiving service. Event types must belong to the kind (lead.captured on a booking subscription is a 400). An empty event_types means every event the kind receives.
Events
| Event | Fires when | Extra block |
|---|---|---|
lead.captured | The request was completed with contact details and consent, confirmed by the person or relayed by their own agent — the lead is deliverable. The event to wire to a CRM. | — |
lead.qualified | A vertical pack recorded a qualification verdict for the lead (sent right after lead.captured). | — (the verdict is in data.lead.qualification) |
lead.verified | Verification strengthened past the agent relay: the phone was verified by SMS code. | — |
lead.delivered | Your team was notified (email, Slack, staff SMS). | — |
lead.outcome_reported | Your team, a Y/N SMS reply, or a connected CRM recorded what happened. | outcome |
booking.requested | A time window was requested; nothing is on a calendar yet. | booking |
booking.confirmed | A slot is on the calendar (a connected scheduler created the booking). | booking |
booking.cancelled | The booking was cancelled by the vendor system. | booking |
call.started | A tracked call from the lead’s number began. | call |
call.completed | The tracked call ended (answered, completed or missed). | call |
The envelope
Every delivery is a POST with the headers from the webhooks reference (X-Inception-Signature, X-Webhook-Event, Webhook-Id, Webhook-Timestamp). A lead.captured delivery to a lead subscription:
{
"apiVersion": "2026-07",
"event": "lead.captured",
"webhookId": "evt_1f6d2c58-90ab-4c3e-8f21-7a4b5d6e9c01",
"timestamp": "2026-09-10T15:04:22.000Z",
"data": {
"lead": {
"ref": "IA-7KQ2ZM",
"status": "delivered",
"intent": "request_consultation",
"tool_name": "request_consultation",
"vertical_slug": "legal-personal-injury",
"stated_facts": { "incident_type": "motor_vehicle", "incident_date": "2026-08-14", "state": "TX", "treatment_started": true },
"verbatim_ask": "I was rear-ended two weeks ago and my neck still hurts. What are my options?",
"derived_context": { "tool_sequence": ["request_consultation"] },
"verification": { "status": "verified", "method": "agent_confirm", "approval": { "method": "card_tap", "source": "stated", "step": "confirm_call" }, "dedupe_hit": false },
"qualification": { "verdict": "may_qualify", "reasons": ["incident within statute window"] },
"value_estimate_cents": 250000,
"provenance": { "surface": "webmcp", "agent_client": "chatgpt", "session_id": "sess_…", "consent_basis": "express_written", "approval_method": "card_tap" },
"routing": { "practice_area": "personal_injury", "location": "Austin, TX" },
"contact_prefs": { "channels": ["phone", "email"], "consent": { "contact": true, "sms": true, "marketing": false } },
"created_at": "2026-09-10T14:59:10.000Z",
"verified_at": "2026-09-10T15:04:22.000Z"
},
"contact": { "name": "Ada Lovelace", "email": "ada@example.com", "phone": "+14155550100" },
"attribution": { "params": "utm_source=inception_lead&utm_campaign=lead.captured" }
}
}
Field notes:
data.lead.statusis read when the event is delivered, which is after we notify your team:deliveredonce an email, Slack or SMS notification reached you,verifiedif none did. Route on the event name, not on the status.data.lead.derived_context.tool_sequenceholds the tool that made the request. The questions the person’s agent asked before it are not in the webhook: they are in the lead email and your Leads inbox under “What their agent asked first”, with a link to replay the conversation in your dashboard.data.lead.refis the reference the agent read back to the customer. Use it as the external id in your CRM so a laterlead.outcome_reportedor a call event lands on the same record.data.contactisnullonbookingsubscriptions and before the person confirmed. Never assume it is present.data.lead.stated_factsis PII-redacted at intake (emails and phone numbers inside free text are masked). Contact lives only indata.contact.data.lead.provenance.agent_clientis the source you have never had before:chatgpt,claude,gemini, … Put it on the CRM record; it is the “agent-sourced vs aggregator” number on day one.data.lead.provenance.approval_methodsays how the person’s approval reached us:card_tap(they confirmed on the card on your page),agent_authorized(their own agent relayed their details and agreement, in one call or at the confirm step),express_form, orsms_confirmed(a text-message code was verified).sms_confirmedis recorded only when the verified number is the number the request was completed with.consent_basisisexpress_writtenwhen the person worked a form themselves (the card on your page, or the express form) andagent_authorizedwhen the consent flags arrived in an agent’s relay, verified number or not. Marketing consent is kept only when the person ticked it on the card: for every other requestcontact_prefs.consent.marketingis stored asfalseeven when it was asserted, because an agent’s word, a form post or a text code is not the person’s own tick.data.outcome(onlead.outcome_reported):{ "outcome": "closed_won", "reason": "retained after consult", "value_cents": 250000, "source": "inbox", "reported_at": "…" }.data.booking(onbooking.*):{ "appointment_id", "status", "slot_source", "starts_at", "ends_at", "tz", "platform", "external_url", "meeting_url" }.data.call(oncall.*):{ "call_sid", "status", "duration_s", "dial_status" }.
Signature verification
The signature is the same scheme as every Inception Agents webhook: t=<unix seconds>,v1=<hex HMAC-SHA256 of "${t}.${rawBody}"> with your whsec_… secret. Verify the raw body before parsing.
Node.js receiver
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_SECONDS = 5 * 60;
function verifySignature(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries((signatureHeader ?? "").split(",").map((kv) => kv.split("=", 2)));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const provided = Buffer.from(parts.v1 ?? "", "hex");
const computed = Buffer.from(expected, "hex");
return provided.length === computed.length && crypto.timingSafeEqual(provided, computed);
}
const app = express();
app.post("/webhooks/inception-leads", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf-8");
if (!verifySignature(raw, req.headers["x-inception-signature"], process.env.INCEPTION_WEBHOOK_SECRET)) {
return res.status(401).end();
}
res.status(200).json({ received: true }); // acknowledge fast; retries are keyed on webhookId
const event = JSON.parse(raw);
if (await alreadyProcessed(event.webhookId)) return;
await upsertCrmLead(event); // your CRM call — see the relays below
});
Path A: Zapier Catch Hook
-
Trigger: Webhooks by Zapier → Catch Raw Hook. Use Catch Raw Hook (not Catch Hook) so the raw body is available for signature verification. Paste the hook URL as the subscription
urlabove, kindlead. -
Action: Code by Zapier → Run JavaScript. Verify the signature and parse the body. Input data:
raw= the raw body,sig= theX-Inception-Signatureheader,secret= your signing secret (store it in the Zap’s input, never in the code).const crypto = require("crypto"); const parts = Object.fromEntries(inputData.sig.split(",").map((kv) => kv.split("=", 2))); const t = Number(parts.t); if (!t || Math.abs(Date.now() / 1000 - t) > 300) throw new Error("stale or missing timestamp"); const expected = crypto.createHmac("sha256", inputData.secret).update(`${t}.${inputData.raw}`).digest("hex"); if (expected !== parts.v1) throw new Error("bad signature"); const event = JSON.parse(inputData.raw); output = { event: event.event, webhook_id: event.webhookId, ref: event.data.lead.ref, status: event.data.lead.status, intent: event.data.lead.intent, verbatim_ask: event.data.lead.verbatim_ask, agent_client: event.data.lead.provenance.agent_client, name: event.data.contact?.name ?? "", email: event.data.contact?.email ?? "", phone: event.data.contact?.phone ?? "", value_dollars: (event.data.lead.value_estimate_cents ?? 0) / 100, outcome: event.data.outcome?.outcome ?? "", booking_starts_at: event.data.booking?.starts_at ?? "", };A thrown error stops the Zap and shows up in Zap History; nothing reaches your CRM on a bad signature.
-
Filter by
event(lead.capturedcreates;lead.outcome_reportedandbooking.confirmedupdate). -
Action: your CRM. HubSpot → Create or Update Contact (email as the match key,
refin a custom property,agent_clientin a custom property) or GoHighLevel → Add/Update Contact with the same mapping. Putverbatim_askin the notes field so the first call opens already knowing the problem.
Path B: Make custom webhook
- Webhooks → Custom webhook. In the webhook’s advanced settings, enable “Get request headers” and set the data structure to a raw string (turn JSON pass-through off) so both the header and the raw body reach the next module. Use the webhook address as the subscription
url. - Tools → Set variable with a Crypto → Create HMAC module: algorithm SHA-256, key = your signing secret, data =
{{t}}.{{rawBody}}wheretis parsed from thex-inception-signatureheader (t=…is the first comma-separated part,v1=…the second). Follow with a Router / Filter whose condition isv1equals the computed HMAC; the failing branch ends the scenario. - JSON → Parse JSON on the raw body, then map
data.lead.ref,data.contact.email,data.lead.verbatim_ask,data.lead.provenance.agent_clientinto your CRM module (HubSpot CRM → Create/Update a Contact, GoHighLevel → Create/Update Contact), a Slack message, or a Google Sheets row.
HubSpot and GoHighLevel via Zapier: the mapping that matters
Both platforms match on email. Map the rest so the lead is worth more than a form fill:
| Envelope field | HubSpot property | GoHighLevel field |
|---|---|---|
data.contact.name / email / phone | first name, email, phone | name, email, phone |
data.lead.ref | custom inception_ref (text) | custom field Inception ref |
data.lead.provenance.agent_client | custom lead_source_agent (dropdown) | tag agent:chatgpt |
data.lead.verbatim_ask | note on the contact | note / first message |
data.lead.qualification.verdict | custom qualification_verdict | tag |
data.lead.value_estimate_cents / 100 | deal amount | opportunity value |
data.outcome.outcome (on lead.outcome_reported) | lifecycle stage / deal stage | pipeline stage |
Route by agent_client and qualification.verdict; never paste them into copy the customer reads.
Close the loop
Outcomes are the reward signal. Record them where you already are:
- In the inbox (Revenue › Leads): Accept / Reject / Booked / Won / Lost / Spam with a reason and a value.
- By SMS: with a staff number configured and the 10DLC campaign verified, the team gets “New client? Reply Y or N” after each lead.
- From your CRM: post the outcome back to
POST /api/v1/leads/:ref/outcome({ "outcome": "closed_won", "value_cents": 250000 }) from a workflow when the deal closes — or let a connected system of record report it.
Every recorded outcome fires lead.outcome_reported to your lead subscriptions, so a Zap can update the CRM stage even when the outcome was recorded elsewhere.
Test before you enable
- Create the subscription (it is active by default;
PATCH { "active": false }while wiring). POST /api/v1/webhooks/subscriptions/:id/test— aleadsubscription gets a syntheticlead.captured(referenceIA-TEST00, contacttest-lead@example.com); abookingsubscription getsbooking.confirmedwith no contact. Pass{ "event": "lead.outcome_reported" }or{ "event": "call.completed" }to see the extra blocks.- Confirm your Zap or scenario verified the signature and would have created the contact, then
PATCH { "active": true }.
Path C: agent-driven setup
Connect the Inception Agents MCP server (mcp.inceptionagents.com) to your own Claude and ask: “create a lead webhook to my Zapier hook and send a test” — create_webhook_subscription with kind: "lead", send_test_webhook, list_webhook_deliveries. The same session can show the funnel (get_lead_funnel), list the leads (list_leads) and record an outcome (record_lead_outcome).