Webhooks
Receive signed events when leads are created, change stage, qualify, or hand off.
The outbound webhook is the other half of a CRM sync: Reply First POSTs a signed JSON event to your endpoint whenever something happens to a lead. Configure one HTTPS endpoint per org and pick the events you want at Settings → Integrations → Webhook.
Delivery headers
Every delivery is a POST with:
| Header | Meaning |
|---|---|
X-Qualifier-Event | The event type, e.g. lead.stage_changed. |
X-Qualifier-Signature | sha256=<hex> — HMAC-SHA256 of the raw body with your signing secret. |
X-Qualifier-Delivery | Unique delivery id. Deliveries are at-least-once — dedupe on this. |
X-Qualifier-Timestamp | ISO timestamp of the delivery attempt. |
Envelope
The body is versioned (version: "1" — breaking changes get a new version, never an edit). The lead snapshot reflects state when the event occurred, not when it was delivered.
{
"id": "d3b0f8e2-1c4a-4b7e-9f21-8a2c0e6d5b10",
"type": "lead.stage_changed",
"version": "1",
"occurred_at": "2026-07-15T09:30:00.000Z",
"org_id": "…",
"data": {
"lead": {
"id": "3f1c2e00-9a1b-4c77-8d2e-2b6a1f0e9c34",
"name": "Jane Buyer",
"phone": "+971501234567",
"email": null,
"channel": "whatsapp",
"qualification_stage": "qualified",
"route_target": "hotLead",
"profile": { "budget": "1.5M AED" },
"external_ref": { "system": "my-crm", "id": "crm-123" },
"conversation_url": "https://replyfirst.ae/app/chats/3f1c2e00-9a1b-4c77-8d2e-2b6a1f0e9c34",
"created_at": "2026-07-15T09:20:00.000Z",
"updated_at": "2026-07-15T09:30:00.000Z"
},
"previous_stage": "qualifying"
}
}Event-specific fields (like previous_stage) sit next to data.lead.
Event catalog
| Type | Fires when |
|---|---|
lead.created | A new inbound contact created a lead (never for leads you created via the API). |
lead.updated | Contact info or profile fields changed. |
lead.stage_changed | qualification_stage transitioned, in any direction. |
lead.qualified | Stage transitioned into qualified — a subset of stage_changed, for consumers that only care about wins. |
handoff.created | The AI requested a human takeover. |
handoff.accepted | An operator took the handoff. |
handoff.released | The operator handed the lead back to the AI. |
Verify the signature
Always verify against the raw request body before parsing JSON, and respond 2xx quickly (do slow work asynchronously — anything else counts as a failure).
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, signatureHeader: string, secret: string) {
const expected = createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
const received = signatureHeader.replace(/^sha256=/, "");
return (
received.length === expected.length &&
timingSafeEqual(Buffer.from(received, "hex"), Buffer.from(expected, "hex"))
);
}import hmac, hashlib
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
received = signature_header.removeprefix("sha256=")
return hmac.compare_digest(received, expected)Delivery & retries
Failed deliveries retry with exponential backoff up to 5 attempts, then drop to a dead-letter queue. Configuration is re-read at delivery time, so disabling the webhook or rotating the secret applies to already-queued events. Endpoints must be public HTTPS — private and loopback addresses are rejected.
Echo-loop contract
To keep a two-way sync from ping-ponging:
- You ignore what you caused. A
PATCH status:"closed"emitslead.stage_changedwith stageclosed; treatclosedas informational and never write it back.closedis also the only stage the API can set, so nothing else can loop. - We don't echo your creates.
POST /api/v1/leadsnever emitslead.created. - Dedupe deliveries on
X-Qualifier-Delivery— retries reuse the same id.