> ## Documentation Index
> Fetch the complete documentation index at: https://api.leadey.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Outbound webhooks

> Leadey posts a signed JSON payload to your endpoint when something changes.

An outbound webhook tells your systems that something happened in Leadey —
a lead's status changed, an opportunity was won, a meeting was cancelled —
without you polling for it.

<Note>
  This is the opposite direction to [Inbound webhooks](/guides/webhooks), which
  are how leads get pushed *into* a Leadey campaign.
</Note>

## Setting one up

Go to **Settings → Webhooks**, add your URL, and choose which events you want.
Selecting none means you receive everything, including events added later.

You can do the same over the API:

```bash theme={"dark"}
curl -X POST https://backend.leadey.ai/api/webhook-subscriptions \
  -H "Authorization: Bearer $LEADEY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/leadey",
    "name": "Ops warehouse",
    "eventTypes": ["lead.status_changed", "opportunity.won"]
  }'
```

The response contains a **signing secret**, shown once and never again. Store it
before you close the dialog.

Your URL must be `https` and publicly reachable. Loopback and private addresses
are rejected — our servers make the request, so accepting them would let a
webhook be pointed at internal infrastructure.

## What arrives

```http theme={"dark"}
POST /hooks/leadey HTTP/1.1
Content-Type: application/json
X-Leadey-Event: lead.status_changed
X-Leadey-Delivery: whd_8Kq2mXvR
X-Leadey-Attempt: 1
X-Leadey-Signature: t=1755640000,v1=5f2c…
```

```json theme={"dark"}
{
  "id": "whe_9wXq2LmT",
  "type": "lead.status_changed",
  "createdAt": "2026-08-20T09:14:02.117Z",
  "organizationId": "org_3Fr4w8Eh",
  "actorUserId": "user_2Nq8Zx",
  "data": {
    "lead": {
      "id": "lead_8Kq2mXvR",
      "name": "Priya Raman",
      "company": "Northwind Logistics",
      "email": "priya.raman@northwind.co.uk",
      "status": "qualified",
      "campaignId": "fnl_Q3OutboundUK",
      "updatedAt": "2026-08-20T09:14:02.104Z"
    },
    "changes": { "status": { "from": "pending", "to": "qualified" } }
  }
}
```

Three things worth knowing about the shape:

* **`actorUserId` is the member who caused it**, or `null` when nothing human
  did — a sweeper, an inbound provider callback, a calendar sync.
* **`changes` carries what actually moved**, so you don't have to diff against
  your own copy to find out.
* **Entities match the REST API.** A `lead` here has the same field names and
  types as `GET /v1/leads/{id}`, so you write one mapper, not two.

## Verifying the signature

**Always verify.** Your endpoint is a public URL; the signature is what tells
you a request genuinely came from Leadey.

The header is `t=<unix seconds>,v1=<hex>`, where the hex is an HMAC-SHA256 over
`"{timestamp}.{raw body}"` using your signing secret. The timestamp is inside
the signed material, so a captured request can't be replayed later with a fresh
clock.

Verify against the **raw request body**, before any JSON parsing — re-serialising
changes the bytes and the signature will not match.

```python theme={"dark"}
import hmac, hashlib, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = int(parts["t"])
    if abs(time.time() - timestamp) > tolerance:
        return False                      # too old — treat as a replay
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

```javascript theme={"dark"}
import crypto from "crypto";

function verify(secret, rawBody, header, tolerance = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => { const i = p.indexOf("="); return [p.slice(0, i), p.slice(i + 1)]; }),
  );
  const t = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Use a constant-time comparison — `compare_digest` or `timingSafeEqual` — not `==`.

## Delivery, retries and replay

Respond with any `2xx` as soon as you've stored the event. **Do your processing
afterwards**: we time out after 10 seconds, and a slow handler turns into a
retry you didn't need.

A failed delivery is retried **six times over about nine hours**, backing off
roughly 10s → 1m → 5m → 30m → 2h → 6h. Redirects are not followed.

Delivery is **at-least-once**, so build your handler to be idempotent. The `id`
on the payload is stable across every retry and across a manual replay — key on
it and ignore anything you've already seen.

If an endpoint fails continuously it is **paused automatically** and the reason
is shown in Settings. Fix the endpoint and re-enable it; that also clears the
failure count.

**Delivery history is kept for 10 days.** In Settings you can filter to just
what's failing and replay anything, or over the API:

```bash theme={"dark"}
# What's broken
curl "https://backend.leadey.ai/api/webhook-deliveries?status=failing" \
  -H "Authorization: Bearer $LEADEY_API_KEY"

# Send it again
curl -X POST https://backend.leadey.ai/api/webhook-deliveries/whd_8Kq2mXvR/replay \
  -H "Authorization: Bearer $LEADEY_API_KEY"
```

`Retrying` means it will arrive on its own. `Gave up` means it won't, and needs
replaying.

## Events

<CardGroup cols={2}>
  <Card title="Leads" icon="user">
    `lead.created` · `lead.updated` · `lead.status_changed` · `lead.deleted`
  </Card>

  <Card title="Opportunities" icon="chart-line">
    `opportunity.created` · `opportunity.updated` · `opportunity.stage_changed` · `opportunity.won` · `opportunity.lost`
  </Card>

  <Card title="Meetings" icon="calendar">
    `meeting.booked` · `meeting.rescheduled` · `meeting.cancelled` · `meeting.attended` · `meeting.no_showed`
  </Card>

  <Card title="Calls" icon="phone">
    `call.completed` · `call.recording_ready`
  </Card>

  <Card title="Tasks" icon="check">
    `task.created` · `task.completed`
  </Card>

  <Card title="Messages and forms" icon="inbox">
    `message.received` · `form.submitted`
  </Card>
</CardGroup>

`call.recording_ready` is separate from `call.completed` because transcription
finishes minutes later — subscribe to it rather than polling after every call.

`GET /api/webhook-event-types` returns the current list, which is always exactly
what can fire.

<Note>
  **Not available.** `lead.merged` (Leadey has no lead merge operation — creating
  a lead that resolves to an existing person returns that person rather than
  duplicating), `lead.owner_changed` (leads have no per-lead owner; opportunity
  owners change via `opportunity.updated`), `message.sent`, and `activity.logged`
  (custom activities don't exist yet). We would rather list an event we don't
  have than publish one that never fires.
</Note>

## Testing before you go live

The **Test** button in Settings — or `POST /api/webhook-subscriptions/{id}/test` —
sends a sample event immediately and shows the real response code and body. The
sample payload carries `"test": true` so nothing downstream mistakes it for a
real change.
