Free during the beta. No credit card.

Sign inStart free

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

EventFires when
approval.requestedAn agent asked a person to approve a send
approval.approvedA person approved the exact draft
approval.rejectedA person rejected it. The document is a draft again
document.sentSigners were sent the document
signer.signedOne signer finished
document.declinedA signer declined, or asked for paper
document.completedEveryone signed and the PDF is sealed
document.voidedThe 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.