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
{"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.
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);
}
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.
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.
Try it in test mode
Free during the beta. Test keys send no email, so you can build the whole flow first.