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

# Webhooks and change recovery

> Verify receivers, authenticate raw payloads, and recover without duplicate work.

<Note>
  This contract applies only to v2 subscriptions. Existing legacy outbound webhooks keep their own payloads and
  signatures; do not replace a legacy verifier with the code below.
</Note>

## Register and verify a receiver

The credential needs `webhooks:manage` and the read scope for every selected
resource event. Persist a subscription UUID, then `PUT /webhooks/{id}` with an
idempotency key and this shape:

```json theme={null}
{"name":"Sales workspace","url":"https://receiver.example.com/events","events":["call.completed","call.disposition_updated","recording.available","transcript.available"]}
```

The response contains the subscription, **not a signing secret**. The subscription
is initially inactive. Complete setup:

1. `POST /webhooks/{id}/rotate-secret` returns `{id,signingSecret,active:false}`.
   Store this one-time secret securely and configure your receiver with it.
2. `POST /webhooks/{id}/verify` sends a signed
   `{type:"webhook.verification",challenge:"random value"}` request.
3. Verify the raw-body signature, then return 2xx JSON containing the identical
   `challenge` within 15 seconds. Successful verification enables the subscription.

Receivers must use public HTTPS on port 443 without URL credentials or fragments.
Private destinations and redirects are rejected. Secret rotation pauses delivery
and immediately replaces the old secret; configure the replacement and verify
again. Never automatically retry rotation or verification after a timeout.

PUT configuration changes pause the subscription, cancel queued deliveries and
require verification again. PATCH `{active:false}` pauses; PATCH `{active:true}`
requires prior verification. Requests already in flight may still arrive.

## Verify signatures before parsing

`PowerDialer-Signature` has the format `t=<Unix milliseconds>,v1=<hex HMAC-SHA256>`.
Sign timestamp + `.` + the exact raw request bytes with the literal signing-secret
string. Do not parse and reserialize JSON before verification. Compare in constant
time and reject timestamps more than five minutes past or future.

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody: Buffer, header: string, secret: string): boolean {
  const match = /^t=(\d{1,16}),v1=([0-9a-f]{64})$/.exec(header);
  if (!match) return false;
  const timestamp = Number(match[1]);
  if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() - timestamp) > 300_000) return false;
  const expected = createHmac('sha256', secret)
    .update(`${match[1]}.`).update(rawBody).digest();
  return timingSafeEqual(expected, Buffer.from(match[2], 'hex'));
}
```

Read the raw bytes before your framework's JSON middleware. After verification,
parse the payload. Challenge requests have no event ID: respond with the challenge.
For resource events, atomically deduplicate and durably enqueue processing before
acknowledging 2xx. Clock synchronization and a persistent inbox are your receiver's
responsibility; this verifier alone does not prevent duplicate processing.

## Event identity and payload

```json theme={null}
{
  "id":"00000000-0000-4000-8000-000000000002",
  "type":"call.updated",
  "apiVersion":"v2",
  "createdAt":"2026-10-08T12:00:00.000Z",
  "workspaceId":"org_synthetic",
  "ownerId":"user_synthetic",
  "resourceType":"calls",
  "resourceId":"synthetic-call-sid",
  "data":{"id":"synthetic-call-sid"}
}
```

Events contain references, not full contact or evidence snapshots. Fetch current
state using the resource type and ID. Deduplicate by event `id`, or subscription
plus event ID for per-subscription processing. `PowerDialer-Delivery-Id` stays
stable per subscription/event; `PowerDialer-Attempt-Id` changes on each send.

The catalog includes contact/list create, update, delete and `access_revoked`
events; `list.membership_updated`; call create, update and delete events;
`call.completed`, `call.disposition_updated`, `recording.available`, and
`transcript.available`. Treat deletes and access revocations as removals from the
corresponding owner's local view. Re-fetch permitted current state when processing
other events. Generic and specialized events can describe the same mutation.
Delivery order is not a resource version, and a completed call need not have all
recordings or transcripts ready.

## Delivery history and replay

Transient network errors, 408, 429 and 5xx retry automatically up to eight attempts.
Exponential delay starts at 30 seconds plus jitter, capped at 3,600 seconds. Other
non-2xx responses fail without automatic retry. Requests time out at 15 seconds
and do not follow redirects. A timeout may occur after the receiver accepted work.

* `GET /webhooks/{id}/deliveries`: latest 100 deliveries and current states.
* `GET /webhooks/{id}/deliveries/{deliveryId}/attempts`: latest 100 attempts.
* `POST /webhooks/{id}/deliveries/{deliveryId}/replay`: requires an idempotency key
  and active verified subscription. Returns 202 `{id,eventId,state:"pending"}`.
  This means queued, not delivered. An active attempt can return 409; wait before
  explicitly retrying.

Replay keeps the event ID. Never use a replay as a reason to place another call,
create another invoice or duplicate a downstream task.

## Recover with the change feed

`GET /changes?after=0&limit=50` requires `changes:read` and the resource read scopes
for the events you need. It returns `{data,nextCursor,hasMore}`. Each change has a
publication `sequence` represented as a decimal string, plus event ID, workspace
(`orgId`), owner, type, resource reference and timestamp.

Never convert the cursor to a JavaScript number. Persist it only after all events
in its page are durably processed or queued. Cursors are assigned after commit;
a late transaction receives a later cursor. Keep polling after `hasMore:false`:
that only describes currently published visible events. Unlike collection pages,
`nextCursor` remains present when a change page is empty.

For initial sync, record changes from cursor zero, enumerate visible resources,
then drain changes and continue polling. Merge by stable resource ID. A new webhook
subscription starts after the latest published workspace cursor; earlier pending
events may still publish afterward. Webhooks do not backfill published history.

Event capture requires a non-revoked, unexpired workspace API key at mutation time.
After a period with no active keys, enumerate again and reconcile local removals:
missing historical events cannot be replayed. Historical record availability may
also require a controlled backfill; see [coverage](/availability).

No automatic event pruning is currently configured. That is not an unlimited
retention commitment; a retention/resynchronization policy must accompany any
future pruning.


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