# Webhooks

> Both signature schemes, the full event catalog, health states, retry schedule, the delivery log, and replay.

Source: https://signclad.com/docs/webhooks/

Webhooks are how you find out what happened without polling. Signclad's are built to be operated: you can tell when an endpoint is failing, see every attempt, and replay anything you missed.

## Creating an endpoint

```bash
curl -X POST https://api.signclad.com/api/v1/webhook_endpoints \
  -H "X-Api-Key: $SIGNCLAD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/webhooks/signclad",
    "description": "Production receiver",
    "event_types": ["document_completed", "document_declined", "document_error"],
    "include_test_mode": false
  }'
```

The same call from Node and Python:

```js
const response = await fetch('https://api.signclad.com/api/v1/webhook_endpoints', {
  method: 'POST',
  headers: {
    'X-Api-Key': process.env.SIGNCLAD_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://app.example.com/webhooks/signclad',
    description: 'Production receiver',
    event_types: ['document_completed', 'document_declined', 'document_error'],
    include_test_mode: false,
  }),
});

if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
const endpoint = await response.json();
// endpoint.secret is returned exactly once. Store it now.
```

```python
import os

import requests

response = requests.post(
    "https://api.signclad.com/api/v1/webhook_endpoints",
    headers={"X-Api-Key": os.environ["SIGNCLAD_API_KEY"]},
    json={
        "url": "https://app.example.com/webhooks/signclad",
        "description": "Production receiver",
        "event_types": ["document_completed", "document_declined", "document_error"],
        "include_test_mode": False,
    },
    timeout=30,
)
response.raise_for_status()
endpoint = response.json()  # endpoint["secret"] is returned exactly once.
```

The secret is shown once. Store it. If your receiver already has a signing key in its configuration, send your own as `secret` instead — at least 32 characters — and skip the deploy that would otherwise exist only to learn ours.

The endpoint is **active immediately** and no `ping` is sent. Add `verify: true` and it goes the other way: the endpoint is created paused, one `ping` is delivered, and it activates only if that lands. The ping's result comes back on the create response as `ping`.

Omit `event_types` to subscribe to everything, now and later; `["*"]` means the same thing, and `events` is accepted as a spelling of the same field. Add `api_application_id` to receive only events for documents created by one application.

## The payload

```json
{
  "event": {
    "id": "evt_01J9K3P2S5N7Q8R1T2V3W4X5",
    "hash": "6f4a9c2e8b1d3f5a7c9e0b2d4f6a8c1e3b5d7f9a0c2e4b6d8f0a2c4e6b8d0f2a",
    "time": 1789050255,
    "type": "document_completed",
    "sequence": 9,
    "related_signer": { "email": "jane@example.com", "name": "Jane Doe" }
  },
  "data": {
    "account_id": "9e0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
    "workspace_id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
    "object": {
      "id": "8f14e45f-ea8f-4b2c-9f0e-1b2c3d4e5f60",
      "status": "Completed",
      "name": "Loan agreement",
      "completed_at": "2026-09-09T17:44:15Z",
      "completed_pdf_url": null,
      "audit_trail_url": null,
      "recipients": [
        { "id": "signer-1", "name": "Jane Doe", "email": "jane@example.com",
          "status": "completed", "signed_at": "2026-09-09T17:44:12Z", "bounced": false }
      ]
    }
  }
}
```

`data.object` is the same body `GET /documents/{id}` returns, plus `workspace_id` and `account_id`. That means the artifact URLs are `null` here, as they are everywhere else: a completed document's PDF comes from `GET /documents/{id}/completed_pdf`, and its audit trail from the same path with `?audit_only=true`. Fetch them when `document_completed` arrives rather than looking for a link in the payload.

Payloads are **snapshots** taken when the event happened. They never change, on a retry or a replay.

## Headers

| Header | Value |
|---|---|
| `X-Signclad-Signature` | `t=<unix>,v1=<hex hmac-sha256(secret, "<t>.<raw body>")>` |
| `X-Signclad-Event` | The event type. |
| `X-Signclad-Event-Id` | The stable `event.id`. Identical across retries and replays. **Dedupe on this.** |
| `X-Signclad-Delivery` | Delivery id, fresh per attempt. |
| `X-Signclad-Attempt` | 1-based attempt number. |

## Verifying: two schemes

### Body signature (use this)

```js
import crypto from 'node:crypto';

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=').map((s) => s.trim()))
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // The header can carry several v1= values during a secret rotation.
  return header
    .split(',')
    .filter((p) => p.trim().startsWith('v1='))
    .some((p) => {
      const given = Buffer.from(p.trim().slice(3), 'hex');
      const want = Buffer.from(expected, 'hex');
      return given.length === want.length && crypto.timingSafeEqual(given, want);
    });
}
```

The same check in Python, against the raw request body your framework received:

```python
import hashlib
import hmac
import time


def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [piece.strip() for piece in header.split(",")]
    stamps = [piece[2:] for piece in parts if piece.startswith("t=")]
    if not stamps or not stamps[0].isdigit():
        return False
    timestamp = int(stamps[0])
    if abs(time.time() - timestamp) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    # The header can carry several v1= values during a secret rotation.
    return any(
        hmac.compare_digest(piece[3:], expected)
        for piece in parts
        if piece.startswith("v1=")
    )
```

Verify against the **raw body**, before any JSON parsing or middleware re-serialisation. Compare in constant time. The tolerance is five minutes.

During a secret rotation the header carries one `v1=` value per valid secret, so rotation needs no cutover on your side.

### Compatibility hash (legacy)

Endpoints registered through the compatibility `POST /hooks` route also include a legacy event hash:

```
event.hash === hex(HMAC-SHA256(key = <hook id>, data = "<event.type>@<event.time>"))
```

This is computed over the header line, not the body, so it does not attest to the payload you received. It exists so an existing integration keeps working unchanged. Move to the body signature when you can.

## The event catalog

Every event Signclad emits — with what makes it fire, how often, what arrives in
`data.object`, and whether it is wire-compatible or a Signclad addition — is on its own page,
generated from the OpenAPI document at build time so it cannot drift:

**[Webhook events →](https://signclad.com/docs/webhook-events/)**

Three cadences catch people out, so they are worth repeating here:

- `document_viewed` fires on **every** view, not just the first.
- `document_in_progress` fires **once**, the first time any value is saved.
- `document_signed` fires **per recipient**, so a two-signer document emits it twice.

## Delivery, retry, and ordering

- At least once. 10-second timeout. Any `2xx` counts as delivered.
- Retries at 1 m, 5 m, 15 m, 1 h, 3 h, 8 h, 24 h, 48 h with jitter of ±10% — nine attempts in all, the first plus eight retries, over about three and a half days.
- `Retry-After` is honoured in one direction only: it can push the next attempt out, up to the 48-hour ceiling, and it can never pull one in ahead of the schedule. A `410` pauses the endpoint immediately, keeping the events replayable rather than dropping them.
- Events are dispatched in per-document `sequence` order, **but a failing delivery never blocks later events.** Each retries independently.

So: dedupe on `event.id`, and where order matters, sort by `event.sequence` within a document rather than assuming arrival order.

Respond quickly and process asynchronously. A receiver that does its work before answering will hit the 10-second timeout and be retried, which is how duplicate processing happens.

## Health states

| State | Trigger | What happens |
|---|---|---|
| `healthy` | Deliveries land. | Nothing. |
| `degraded` | Failures for 15 minutes without a success (at least 3 in a row), or over 25% failures in an hour (minimum 20 deliveries) while the latest attempt is still failing. | Email to workspace admins, in-app banner, daily digest while it persists. |
| `failing` | 25 consecutive failures, or 100% over 24 hours. | Circuit breaker stops attempting and probes hourly. |
| `disabled` | 5 days of continuous failure. | Endpoint switched off, with a notice and one-click re-enable that offers to replay everything missed. |

A brief blip on your side, such as a burst of `503`s while you deploy, does not raise an alert: those deliveries are retried anyway. An endpoint leaves `degraded` once the latest attempt succeeds and the last hour is back under 25% failures, so one that fails intermittently stays `degraded` instead of flapping, and recovery after a busy outage can take up to an hour.

```http
GET /webhook_endpoints/{id}/health
```

```json
{ "state": "healthy", "consecutive_failures": 0,
  "success_rate_1h": 1, "success_rate_24h": 0.998, "p95_latency_ms": 210,
  "last_success_at": "2026-09-09T17:43:58Z",
  "last_failure_at": "2026-09-08T22:11:04Z",
  "last_failure_reason": "502 Bad Gateway", "pending_count": 0 }
```

## The delivery log

```http
GET /webhook_endpoints/{id}/deliveries?status=failed&cursor=...
```

Every delivery with its attempt timeline, the request headers and body, the response status and a body excerpt, and the latency of each attempt. Filter by event type, status, or document. Dead letters are kept 30 days.

## Replay

```http
POST /webhook_endpoints/{id}/deliveries/{delivery_id}/replay
```

Replay one delivery, a selection, or a date range, or resend everything that failed since a moment you choose. On a replay `event.id`, `event.time`, and the payload are unchanged; the signature timestamp, `X-Signclad-Delivery`, and `X-Signclad-Attempt` are fresh.

## Rotating a secret

```http
POST /webhook_endpoints/{id}/rotate_secret
```

Both secrets are valid during the overlap and the signature header carries a `v1=` value for each. Deploy the new secret, confirm deliveries verify, then end the overlap.

## Debugging before you have a receiver

Every workspace has a hosted inbox URL that records requests. Point an endpoint at it and look at real payloads before writing a line of code.

## Related

- [Webhook events](https://signclad.com/docs/webhook-events/) — the generated catalog, with cadence and compatibility per event.
- [Errors and rate limits](https://signclad.com/docs/errors-and-rate-limits/)
- [Sandbox and test mode](https://signclad.com/docs/sandbox-and-test-mode/)
- [Embedded builders and the compatibility shim](https://signclad.com/docs/embedded-builders/) — why browser messages are hints and these are the record.
