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

# RevDesk Webhooks

> Subscribe to RevDesk call, SMS, contact, number-lifecycle, registration, booking, task, and website-test events delivered to your URL.

RevDesk POSTs an event to your URL whenever something happens in your workspace: a call ends, an SMS
is delivered, a number is provisioned. Subscribe once per event type; we deliver as the events occur.

## Subscribe

```bash theme={null}
curl -X POST https://api.revdesk.com/v1/webhook_subscriptions \
  -H "Authorization: Bearer $REVDESK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "webhook_url": "https://example.com/hooks/revdesk", "event_type": "call_completed" }'
# → { "data": { "id": "whk_…", "secret": "<64-char hex>" } }
# `secret` signs every delivery (X-RevDesk-Signature) and is shown only here — store it now.
```

| Field | Required | Description |
| - | - | - |
| `webhook_url` | yes | HTTPS URL that receives the `POST`. |
| `event_type` | yes | One of the event types below. |

## List subscriptions

```bash theme={null}
curl https://api.revdesk.com/v1/webhook_subscriptions \
  -H "Authorization: Bearer $REVDESK_API_KEY"
# → { "data": [ { "id": "whk_…", "webhook_url": "…", "event_type": "call_completed", "active": true, "created_at": "…" } ], "meta": { … } }
# Signing secrets are shown once at creation and are never returned here.
```

## Unsubscribe

```bash theme={null}
curl -X DELETE https://api.revdesk.com/v1/webhook_subscriptions/whk_… \
  -H "Authorization: Bearer $REVDESK_API_KEY"
# → { "data": { "id": "whk_…", "deleted": true } }
```

## Event types

<Note>
  `call_completed` fires on every call end. The **disposition** events below fire *in addition*, only on the
  matching outcome. Subscribe to them to branch (retry, SMS fallback) without parsing call records.
  Answered/successful calls emit no disposition event.
</Note>

| `event_type` | Fires when |
| - | - |
| `new_call` | A call starts. |
| `call_completed` | A call ends (any outcome). |
| `call_no_answer` | A call ended unanswered (rang out). |
| `call_voicemail` | A call reached voicemail / an answering machine. |
| `call_busy` | The destination was busy. |
| `call_failed` | A call failed (declined, unreachable, carrier rejection). |
| `contact_replied` | An inbound SMS was received. |
| `sms_sent` | An outbound SMS was accepted by the carrier. |
| `sms_delivered` | An outbound SMS was delivered to the handset. |
| `sms_failed` | An outbound SMS failed / was undelivered. |
| `contact_created` | A new contact was created. |
| `number_purchased` | A phone number finished provisioning and went active. |
| `number_released` | A phone number was released from your workspace. |
| `number_updated` | A phone number's settings changed (name, CNAM, recording, agent assignment, …). |
| `texting_registration_status_changed` | Your A2P texting registration (brand or campaign) was approved or rejected by carriers. On campaign approval the payload lists `texting_enabled_numbers`, the moment to start sending. |
| `new_appointment` | A booking was created. |
| `registration_submitted` | A [number registration](/concepts/number-registration) was submitted to the carrier. |
| `registration_confirmed` | A number registration was accepted. |
| `registration_failed` | A number registration was rejected. The payload carries the reason. |
| `job_started` | A [Task](/concepts/tasks) opened a pursuit. The legacy event name is retained for compatibility. |
| `job_progressed` | A task took an action. Carries `channel`, `action`, and the `rationale` behind it. |
| `job_outcome_recorded` | A task recorded an outcome, with the `evidence` behind it. |
| `job_completed` | A task reached a terminal status. |
| `optimize.test.created` | A [website test](/api-reference/optimize) was created. |
| `optimize.test.started` | A website test started or resumed serving variants. |
| `optimize.test.completed` | A website test finished without a winner being declared. |
| `optimize.test.winner` | A website test finished with a winner. The payload's `test.winner_variant_id` names it. |
| `optimize.test.shipped` | A website test's variant was made permanent on the page. |
| `optimize.conversion.created` | A conversion was recorded for a website test through the API. |

## Delivery

Each event is a `POST` with a JSON body to your `webhook_url`. Delivery is fire-and-forget; return a
`2xx` quickly and do any heavy work asynchronously. Subscribe to the same event type multiple times
to fan out to multiple URLs.

<Warning>
  **Two delivery shapes.** Call-disposition, SMS, and task events are delivered **flat** (the event
  object *is* the request body). Number-lifecycle events are delivered **enveloped** (`triggerEvent` /
  `createdAt` / `payload`). Both shapes are **signed**. Match the shape to the event family you
  subscribed to (see the examples below), but verify the signature the same way for all of them.

  Flat deliveries carry no event-name field. Branch on the payload instead: call and SMS events on
  `status`, task events on `status` plus the fields unique to each (`job_progressed` carries
  `rationale`, `job_outcome_recorded` carries `outcome`). The one exception is the website test
  family: `optimize.*` deliveries are flat and carry the event name in `event`.
</Warning>

### Signature verification (all events)

Every subscription has a signing secret (generated at creation, shown once; rotate it to get a new
one). Every delivery carries three headers:

| Header | Meaning |
| - | - |
| `X-RevDesk-Event-Id` | Unique id for this event. **Dedupe on it.** A delivery with an id you have already processed is a retry or a replay; ignore it. |
| `X-RevDesk-Timestamp` | Unix seconds when the event was signed. Reject anything outside a few minutes of your clock. |
| `X-RevDesk-Signature` | `v2=<hex HMAC-SHA256 over "{eventId}.{timestamp}.{rawBody}">`. During a secret rotation a second comma-separated `v2=` value (signed with the previous secret) rides alongside, so a delivery verifies if **either** matches. |

Signing over `{eventId}.{timestamp}.{rawBody}` rather than the body alone is what makes a captured
request non-replayable. Both moving parts are inside the signature: the timestamp can't be moved
forward, and the event id can't be swapped. That second property is what lets you dedupe safely. If
the id weren't signed, anyone who captured one delivery could resend it with a fresh id inside the
tolerance window and your dedupe would wave it through as a new event.

```js theme={null}
import crypto from "node:crypto";

// Verify a RevDesk webhook. Returns true only for a fresh, correctly-signed request.
function verifyWebhook(rawBody, headers, secret, { toleranceSeconds = 300 } = {}) {
  const timestamp = Number(headers["x-revdesk-timestamp"]);
  if (!Number.isFinite(timestamp)) return false;
  // Replay defense: reject stale (or future-dated) deliveries.
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;

  // The event id is part of the signed material, so verify against the id as received.
  const eventId = headers["x-revdesk-event-id"];
  if (!eventId) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${eventId}.${timestamp}.${rawBody}`)
    .digest("hex");
  const expectedBuf = Buffer.from(expected);

  // Accept any of the comma-separated v2 signatures (handles secret rotation).
  return (
    (headers["x-revdesk-signature"] ?? "")
      .split(",")
      .map((s) => s.trim())
      .filter((s) => s.startsWith("v2="))
      .map((s) => Buffer.from(s.slice(3)))
      // Length-check BEFORE timingSafeEqual, since it throws on unequal-length buffers.
      .some((sig) => sig.length === expectedBuf.length && crypto.timingSafeEqual(sig, expectedBuf))
  );
}
```

**Verify first, then dedupe on `X-RevDesk-Event-Id`** (e.g. a short-lived set or a unique column) so a
retried delivery is processed at most once. The order matters: the id only means something once the
signature covering it has checked out.

<Note>
  **Legacy header (deprecated).** Older integrations verified an `X-revdesk-Signature-256` /
  `X-RevDesk-Signature-256` header, a bare HMAC of the body with no timestamp. It still ships
  alongside the new headers during a deprecation window but offers no replay protection at all;
  migrate to the `X-RevDesk-Signature` verifier above.

  **`v1=` signatures are retired.** A short-lived earlier version of `X-RevDesk-Signature` signed
  `{timestamp}.{rawBody}`, leaving the event id unauthenticated. It is no longer sent. If your
  verifier filters for the `v1=` prefix, switch it to `v2=` and add the event id to the signed
  string as shown above.
</Note>

#### Rotating a signing secret

Rotate from the dashboard or the API to get a fresh secret (shown once). For a grace period RevDesk
signs each delivery with **both** the new and previous secret, so you can deploy the new secret
without dropping in-flight events; once your consumers use the new secret, the old one stops signing.

### Payload examples

Call disposition (`call_no_answer`, `call_voicemail`, `call_busy`, `call_failed`) — **flat** (signed like every event):

```json theme={null}
{
  "id": "call_abc123",
  "from_number": "+15551234567",
  "to_number": "+15557654321",
  "direction": "outbound",
  "duration_seconds": 0,
  "status": "VOICEMAIL",
  "failure_code": "voicemail_reached",
  "failure_category": "voicemail",
  "disconnection_reason": null,
  "created_at": "2026-06-30T19:30:00.000Z"
}
```

Outbound SMS (`sms_sent`, `sms_delivered`, `sms_failed`) — **flat** (signed like every event):

```json theme={null}
{
  "id": "msg_uuid",
  "phone_number": "+15557654321",
  "from_number": "+15551234567",
  "direction": "outbound",
  "status": "delivered",
  "contact_token": "…",
  "provider_id": "…",
  "error_message": null,
  "channel": "sms",
  "created_at": "2026-06-30T19:30:00.000Z"
}
```

Task progress (`job_started`, `job_progressed`, `job_outcome_recorded`, `job_completed`) — **flat**
(signed like every event). The `job_*` event names and `job_id` field are stable legacy contract
names. Every task event carries the five base fields; `job_progressed` adds the
action and the reasoning behind it:

```json theme={null}
{
  "job_id": "job_abc123",
  "type": "lease_end_retention",
  "status": "OPEN",
  "objective": "Book a test drive with Dana Whitfield",
  "contact_id": "c_123",
  "task_id": "task_789",
  "channel": "IMESSAGE",
  "action": "send_message",
  "rationale": "Replied on iMessage twice and never answered a call, so text rather than dial."
}
```

Website tests (`optimize.test.created`, `optimize.test.started`, `optimize.test.completed`, `optimize.test.winner`,
`optimize.test.shipped`) are delivered **flat**, with the event name in `event` and the test in `test`, in the same
shape as `GET /v1/optimize/tests/{id}`. `optimize.conversion.created` carries `conversion` instead:

```json theme={null}
{
  "event": "optimize.test.winner",
  "occurred_at": "2026-09-09T12:00:00.000Z",
  "test": {
    "id": "…",
    "name": "Pricing headline",
    "status": "completed",
    "page_url": "/pricing",
    "winner_variant_id": "…",
    "variants": [{ "id": "…", "name": "Control", "is_control": true, "weight": 1, "ops": [], "redirect_url": null }]
  }
}
```

Number lifecycle (`number_purchased`, `number_released`, `number_updated`) — **enveloped**. The
event data is nested under `payload`, and `triggerEvent` is the internal event name
(`number.purchased` / `number.released` / `number.updated`):

```json theme={null}
{
  "triggerEvent": "number.purchased",
  "createdAt": "2026-06-30T19:30:00.000Z",
  "payload": {
    "id": 4821,
    "phone_number": "+15551234567",
    "area_code": "555",
    "status": "ACTIVE",
    "organization_id": "…"
  }
}
```

`number.updated` additionally carries `changed_fields` — the snake\_case names of the settings the
update touched (e.g. `["cnam_display_name", "agent_id"]`).

Signed like every other event: verify `X-RevDesk-Signature` with the
[verifier above](#signature-verification-all-events). Only the body shape differs.

## Receiving leads

Webhook subscriptions deliver RevDesk events **out** to your systems. To send a new lead **into**
RevDesk, create a lead source in **Directory → Lead sources** and use its source-specific
`POST /api/webhooks/leads/{intakeToken}` endpoint. Lead intake uses its own rotatable URL token and
secret; it does not use an API key or a webhook-subscription signing secret. See
[Tasks and lead sources](/getting-started/features/tasks-and-lead-sources#generic-webhook) for the
payload, authentication, field mapping, response codes, health checks, and rotation procedure.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.