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

# Delivery

> Wakeup payloads, signature verification, and retry semantics for webhook deliveries.

Webhook deliveries are **wakeups, not data**. The payload tells you that new events are readable on the feed; it never carries event contents. On receiving one, poll `GET /api/v1/events` from your last cursor — see the [events & webhooks overview](/docs/webhooks/overview) for the feed model.

## The wakeup payload

Deliveries are an HTTPS `POST` to your subscription URL with a minimal JSON body:

```json theme={null}
{
  "id": "5b1f6c1e-0000-4000-8000-000000000000",
  "eventType": "sync.events_available",
  "occurredAt": "2026-08-13T03:00:00.000Z",
  "organizationId": "org-example-id",
  "latestCursor": "456",
  "eventCount": 12,
  "entities": ["case", "document"]
}
```

* `latestCursor` is the highest feed cursor readable at send time — if it is beyond the last cursor you processed, you have events to fetch.
* `eventCount` is how many events were promoted in the batch that triggered this wakeup, and `entities` summarizes which entity families they touch. Both are hints for scheduling, not a contract: always resume from your own cursor rather than counting.

## Request headers

| Header | Contents |
| - | - |
| `X-Webhook-ID` | Unique id for this delivery (also the `id` in the body). |
| `X-Webhook-Event` | The wakeup event type (`sync.events_available`). |
| `X-Webhook-Timestamp` | Unix seconds at send time. |
| `X-Webhook-Signature` | `sha256=<hex>` — HMAC-SHA256 of `<timestamp>.<rawBody>` keyed with your subscription secret. |

## Verifying signatures

Compute HMAC-SHA256 over the literal string `<timestamp>.<rawBody>` — the timestamp from `X-Webhook-Timestamp`, a dot, then the **raw request body bytes** (before any JSON parsing) — using the signing secret returned when the subscription was created (or last rotated). Compare against the hex digest after the `sha256=` prefix using a constant-time comparison.

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

function verifyWebhook(rawBody: string, headers: Headers, secret: string): boolean {
  const timestamp = headers.get('x-webhook-timestamp');
  const signature = headers.get('x-webhook-signature');
  if (!timestamp) return false;

  // Reject stale deliveries (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  // Strictly parse the header before comparing: timingSafeEqual THROWS on
  // length mismatch, and Buffer.from(x, 'hex') silently truncates at the
  // first invalid pair — so malformed input must be rejected here, not
  // passed through.
  const match = /^sha256=([0-9a-fA-F]{64})$/.exec(signature ?? '');
  if (!match) return false;

  const provided = Buffer.from(match[1], 'hex');
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest();
  return (
    provided.length === expected.length &&
    crypto.timingSafeEqual(provided, expected)
  );
}
```

Receivers **must** verify the signature and **should** reject timestamps older than 5 minutes.

## Endpoint requirements

* The subscription URL must be **HTTPS**, on a host present in your API key's **webhook allowed hosts** allowlist. The allowlist is fail-closed: while it is empty, no subscription URL is accepted and no deliveries are attempted.
* The URL is re-validated against the allowlist at delivery time, not just at subscription time.
* Respond with a 2xx status within **10 seconds**. Anything else — timeout, non-2xx, connection failure — counts as a failed delivery.
* Return quickly: acknowledge first, then poll the feed asynchronously. Doing feed processing inline in the webhook handler is the most common cause of timeouts.

## Retry semantics

Wakeups are **best-effort and lossy by design**. There is no delivery queue to drain and no replay endpoint — none is needed, because the [event feed](/docs/webhooks/overview) is the source of truth and cursor-resume makes a missed wakeup harmless.

Poll `GET /api/v1/events` on every wakeup **and** on your own schedule (we recommend at least every 5 minutes) regardless of webhook health. Because the polling loop is your source of truth, it also doubles as your health check: if wakeups stop arriving but the feed keeps advancing, your endpoint (or its allowlist entry) needs attention.
