# Webhooks

Webhooks tell your system when a document moves, so you don't have to poll. Each one is signed, and you should check that signature before you trust it.

## Add a webhook

```
curl https://docuinked.com/v1/webhooks \
  -H "Authorization: Bearer $DOCUINKED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/docuinked"}'
```

The reply holds a `secret` that starts with `whsec_`. It is shown once, so store it now. The URL must be plain public https on the default port. Private and internal addresses are refused.

## Events

| Event | Fires when |
|---|---|
| `approval.requested` | An agent asked a person to approve a send |
| `approval.approved` | A person approved the exact draft |
| `approval.rejected` | A person rejected it. The document is a draft again |
| `document.sent` | Signers were sent the document |
| `signer.signed` | One signer finished |
| `document.declined` | A signer declined, or asked for paper |
| `document.completed` | Everyone signed and the PDF is sealed |
| `document.voided` | The document was cancelled |

## What you receive

```json
{"id": "evt_...", "type": "document.completed", "document": "doc_...", "created_at": "2026-11-07T15:04:05Z"}
```

The body names the document and carries no document content. Fetch details with `get_document`.

## Verify the signature

Each delivery has a header:

```
DocuInked-Signature: t=1794150245,v1=5f8a...
```

`t` is a Unix timestamp. `v1` is the hex HMAC-SHA256 of the string `t.body`, using your webhook secret, where `body` is the raw request body. Check it before you parse the JSON.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(header, rawBody, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const want = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
  const got = Buffer.from(parts.v1 ?? "", "hex");
  return got.length === want.length && timingSafeEqual(got, want);
}
```

```python
import hashlib, hmac, time

def verify(header, raw_body, secret):
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        return False
    want = hmac.new(secret.encode(), f"{parts['t']}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, parts.get("v1", ""))
```

The 300-second window stops an old delivery being replayed. Use the raw bytes, not re-encoded JSON, or the hash won't match.

## Retries

Answer with any 2xx status within 30 seconds. Anything else counts as a failure, and we retry with a growing delay, up to 8 attempts. Deliveries can arrive twice or out of order, so use the event `id` to skip repeats and read current state with `get_document`.

## Common questions

### Do webhooks fire for test documents?

Yes. They use the same path as live events, so you can build your receiver in [test mode](/docs/test-mode).

### Can I rotate a webhook secret?

Add a new webhook, move your receiver to the new secret, then remove the old one with `delete_webhook`. There is no in-place rotation.

### Why is the document body not in the webhook?

A webhook URL is a place data leaves your control. We send only the event and the document ID. Your server then fetches what it needs with its own key.
