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:

HeaderMeaning
X-Qualifier-EventThe event type, e.g. lead.stage_changed.
X-Qualifier-Signaturesha256=<hex> — HMAC-SHA256 of the raw body with your signing secret.
X-Qualifier-DeliveryUnique delivery id. Deliveries are at-least-once — dedupe on this.
X-Qualifier-TimestampISO 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

TypeFires when
lead.createdA new inbound contact created a lead (never for leads you created via the API).
lead.updatedContact info or profile fields changed.
lead.stage_changedqualification_stage transitioned, in any direction.
lead.qualifiedStage transitioned into qualified — a subset of stage_changed, for consumers that only care about wins.
handoff.createdThe AI requested a human takeover.
handoff.acceptedAn operator took the handoff.
handoff.releasedThe 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:

  1. You ignore what you caused. A PATCH status:"closed" emits lead.stage_changed with stage closed; treat closed as informational and never write it back. closed is also the only stage the API can set, so nothing else can loop.
  2. We don't echo your creates. POST /api/v1/leads never emits lead.created.
  3. Dedupe deliveries on X-Qualifier-Delivery — retries reuse the same id.

On this page