Chaindoc webhooks
Webhooks push event data to your server the moment something happens in Chaindoc. No polling, no delays. This page covers setup, the full event list, HMAC-SHA256 signature verification, retries, and replay protection.
Requirements
Business plan subscription (webhooks are available only on the Business plan).
An HTTPS endpoint on your server. Chaindoc can't reach localhost or private networks.
A webhook secret (a high-entropy random string, at least 32 chars) for HMAC verification.
Overview
Instead of polling the API, webhooks tell your server what happened as soon as it happens. Use them to sync document status, trigger post-signing workflows, kick off invoicing, send notifications, and keep your database in step with Chaindoc state. Learn more in the RFC 2104 — HMAC.
- Instant delivery with up to 3 automatic retries (1 min → 5 min → 15 min backoff)
- HMAC-SHA256 signature verification on every payload, with a 5-minute replay-protection window
- 17 emitted event names covering document, signature request, contract, invoice, and transaction lifecycle changes
- Delivery status and failure stats tracked per API key in your dashboard
Setup
Step 1. Create an API key
Go to Settings → API Access in your Chaindoc dashboard and create an API key (or pick an existing one). Each key carries its own webhook configuration, so you can send events to different endpoints per key if you need that separation. Learn more in the webhook.site (testing tool).
Step 2. Configure the webhook in the dashboard
Open your API key's settings and fill in the webhook section:
- Webhook URL: the HTTPS endpoint on your server that will receive events.
- Webhook secret: a random high-entropy string used to sign every delivery. Save it in your environment variables. you'll need it to verify incoming requests.
- Enabled: toggle on once your endpoint is ready to accept traffic.
Why dashboard only?
Webhook configuration lives in the dashboard surface, not the public /api/v1/* API. This keeps the secret out of your integration code and prevents API keys from reconfiguring their own delivery target at runtime.
Step 3. Implement your endpoint
On your server, expose an HTTPS route that accepts POST requests with a JSON body. Your handler must: (1) verify the signature, (2) respond with a 2xx status within 30 seconds, and (3) process the event. ideally in a background job so the HTTP response returns quickly.
Raw body is required
The HMAC signature is computed over the raw request body bytes plus the timestamp. If your framework auto-parses JSON (e.g. express.json()), the bytes can shift and verification will fail. Always capture the raw body on the webhook route. the examples below use the canonical approach for each framework.
import express from 'express';
import { Chaindoc } from '@chaindoc_io/server-sdk';
const app = express();
app.post(
'/webhooks/chaindoc',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('X-Chaindoc-Signature') ?? '';
const timestamp = req.header('X-Chaindoc-Timestamp') ?? '';
const rawBody = (req.body as Buffer).toString('utf8');
const result = Chaindoc.webhooks.verify(
rawBody,
signature,
timestamp,
process.env.CHAINDOC_WEBHOOK_SECRET!,
);
if (!result.valid || !result.envelope) {
return res.status(401).send('Invalid signature');
}
const { id, type, data } = result.envelope;
// Respond fast. run real work in a background job keyed by `id` for idempotency
res.status(200).end();
enqueueBackgroundJob({ id, type, data }).catch(console.error);
},
);
app.listen(3000);Delivery headers
Every webhook request Chaindoc sends includes these headers:
Content-Type: application/jsonUser-Agent: Chaindoc-Webhook/1.0X-Chaindoc-Event. the event name (e.g.document.created). Convenient for routing before you parse the body.X-Chaindoc-Delivery-Id. a unique UUID for this delivery attempt. Use it as the idempotency key.X-Chaindoc-Timestamp. ISO 8601 UTC timestamp of when the delivery was issued (e.g.2026-04-21T10:35:00.000Z). Used in the HMAC and for the replay window.X-Chaindoc-Signature. HMAC-SHA256 signature in the formv1=<hex>(only present when a webhook secret is configured).
Payload envelope
All events share the same top-level envelope. The event-specific fields live inside data:
{
"id": "21096b94-498f-4a2d-9795-e810edc2c9a9",
"type": "document.created",
"createdAt": "2026-04-21T10:35:00.000Z",
"data": { /* event-specific fields. see below */ }
}id. unique delivery UUID, identical toX-Chaindoc-Delivery-Id. Use it for idempotency.type. event name (see the full list below).createdAt. ISO 8601 UTC timestamp.data. event-specific object.
All event types
Chaindoc emits 17 public webhook event names, grouped into five domains. Your endpoint receives an envelope with type, and filtering happens on your side.
Documents
document.created. a new document is created via the API.document.verified. a document version has been anchored on the blockchain (dataincludestxHashandchainId).document.signed. all required signatures on a document are collected.
Signature requests
signature.request.created. a new signature request has been sent to recipients.signature.request.completed. every signer has completed signing.signature.request.rejected. a signer declined the request.
Contracts
contract.created. a contract is created indraftstatus.contract.status_changed. a contract transitions between lifecycle statuses (draft→pending_signature,pending_signature→active, etc.).contract.signed. both parties have signed a contract.contract.cancelled. adraftorpending_signaturecontract is cancelled.contract.terminated. anactivecontract is terminated (formutual_approvalcontracts, fires when termination is fully confirmed).
Invoices
invoice.created. an invoice is generated (manually or auto-generated from recurring payment terms).invoice.sent. an invoice has been dispatched to the contragent.invoice.paid. an invoice is fully paid (via Stripe charge or manualmark-paid).invoice.cancelled. an invoice is voided.
Transactions
transaction.created. a payment transaction is initiated against an invoice.transaction.updated. a transaction status changes (e.g.PENDING→SUCCESS/FAILED).
Sample payloads
Representative envelopes for common event names. Fields shown in data are the stable public identifiers and status fields you should rely on; extra fields may be added over time.
document.verified
{
"id": "21096b94-498f-4a2d-9795-e810edc2c9a9",
"type": "document.verified",
"createdAt": "2026-04-21T10:35:00.000Z",
"data": {
"documentId": "86840ee4-8bf2-4a91-a289-e99d8307ec25",
"versionId": "f0b7721f-0399-4035-9b69-7b95d3a367f0",
"verificationId": "6e10b4db-0d41-4335-8d29-6cd4f7e31c21",
"versionHash": "0x123abc...",
"txHash": "0x789ghi...",
"chainId": "1564830818",
"status": "verified",
"verifiedAt": "2026-04-21T10:35:00.000Z"
}
}signature.request.completed
{
"id": "c1a2b3c4-d5e6-4890-8abc-ef1234567890",
"type": "signature.request.completed",
"createdAt": "2026-04-21T10:32:00.000Z",
"data": {
"signatureRequestId": "21096b94-498f-4a2d-9795-e810edc2c9a9",
"documentId": "86840ee4-8bf2-4a91-a289-e99d8307ec25",
"versionId": "f0b7721f-0399-4035-9b69-7b95d3a367f0",
"documentTitle": "Service Agreement",
"status": "completed",
"completedAt": "2026-04-21T10:32:00.000Z",
"signers": [
{
"signerId": "4e2b68c3-0af0-4efc-9db1-7f2f7a03ad83",
"email": "signer1@example.com",
"name": "Signer One",
"status": "signed",
"signedAt": "2026-04-21T10:30:00.000Z"
},
{
"signerId": "2dd3ad8d-ff6b-48ef-b4b1-43a377c55c3f",
"email": "signer2@example.com",
"name": "Signer Two",
"status": "signed",
"signedAt": "2026-04-21T10:32:00.000Z"
}
]
}
}signature.request.rejected
{
"id": "5e6f7a8b-9c0d-4e1f-8a3b-456789abcdef",
"type": "signature.request.rejected",
"createdAt": "2026-04-21T10:30:00.000Z",
"data": {
"signatureRequestId": "21096b94-498f-4a2d-9795-e810edc2c9a9",
"documentId": "86840ee4-8bf2-4a91-a289-e99d8307ec25",
"versionId": "f0b7721f-0399-4035-9b69-7b95d3a367f0",
"documentTitle": "Service Agreement",
"status": "cancelled",
"rejectedAt": "2026-04-21T10:30:00.000Z",
"rejectedVia": "document",
"rejectedByEmail": "signer1@example.com",
"rejectedByName": "Signer One",
"reason": "Terms not acceptable"
}
}contract.signed
{
"id": "8a9b0c1d-2e3f-4a5b-8c7d-8e9f0a1b2c3d",
"type": "contract.signed",
"createdAt": "2026-04-25T14:30:00.000Z",
"data": {
"contractId": "c1a2b3c4-d5e6-7890-abcd-ef1234567890",
"signingRequestId": "21096b94-498f-4a2d-9795-e810edc2c9a9",
"status": "active"
}
}invoice.paid
{
"id": "9f8e7d6c-5b4a-4210-bedc-ba9876543210",
"type": "invoice.paid",
"createdAt": "2026-05-10T09:15:00.000Z",
"data": {
"invoiceId": "i9f8e7d6-c5b4-4a32-9876-1234567890ab",
"contractId": "c1a2b3c4-d5e6-7890-abcd-ef1234567890",
"invoiceNumber": "INV-2026-0042",
"amount": "2500.00",
"paidAmount": "2500.00",
"currencyCode": "USD",
"paidAt": "2026-05-10T09:15:00.000Z",
"transactionId": "t5e6f7a8-b9c0-4d1e-2f3a-456789abcdef"
}
}transaction.updated
{
"id": "3a4b5c6d-7e8f-4a0b-9c2d-3e4f5a6b7c8d",
"type": "transaction.updated",
"createdAt": "2026-05-10T09:15:00.000Z",
"data": {
"transactionId": "t5e6f7a8-b9c0-4d1e-2f3a-456789abcdef",
"invoiceId": "i9f8e7d6-c5b4-4a32-9876-1234567890ab",
"contractId": "c1a2b3c4-d5e6-7890-abcd-ef1234567890",
"status": "SUCCESS",
"amount": "2500.00",
"currencyCode": "USD",
"paymentMethodType": "card"
}
}Signature verification
Every delivery is signed with HMAC-SHA256. The signature is computed over {timestamp}.{rawBody} using your webhook secret. where {timestamp} is the value from X-Chaindoc-Timestamp and {rawBody} is the raw UTF-8 request body (not re-stringified JSON). The result is hex-encoded and sent as X-Chaindoc-Signature: v1=<hex>.
Use the Server SDK helper
Chaindoc.webhooks.verify(rawBody, signature, timestamp, secret) handles the HMAC check, the v1= prefix parsing, timing-safe comparison, and the replay window automatically. The Node / Next examples in Setup use it. For other languages, the Python sample above shows the equivalent scheme step by step.
Replay protection
The Server SDK rejects deliveries whose X-Chaindoc-Timestamp is more than 5 minutes away from your server's current time (in either direction. too old or dated in the future). This protects against replayed requests even if an attacker got hold of a signed payload. When implementing verification from scratch in another language, enforce the same 5-minute window.
Security checklist
- Verify every request. never accept unsigned payloads in production.
- Use timing-safe comparison (
crypto.timingSafeEqual,hmac.compare_digest, etc.). equality operators leak timing information. - Store the webhook secret in a secret manager or environment variable, never in source.
- Rotate the secret if you suspect compromise; the dashboard supports rotation without changing the URL.
- Expose the webhook endpoint over HTTPS only; reject plain HTTP.
Retries and failure handling
A delivery is considered successful when your endpoint returns an HTTP 2xx status code within 30 seconds. Anything else (non-2xx, timeout, TCP/TLS error) triggers retry logic:
- Attempt 1. immediate delivery.
- Attempt 2. after 1 minute.
- Attempt 3. after 5 more minutes.
- Attempt 4. after 15 more minutes, then the delivery is given up on.
After repeated failures across many events, Chaindoc auto-disables webhook delivery on the affected API key to protect your system and ours. You can see delivery logs, success rate, and manually re-enable delivery under Settings → API Access → Webhook logs.
Don't do work synchronously
Return 2xx quickly and push the event to a background queue. Long-running handlers inside the HTTP response risk timeouts, which count as failures and eat into your retry budget.
Testing locally
Webhooks can't reach localhost, so during development expose your machine to the public internet with a tunnel tool:
# ngrok
ngrok http 3000
# Use the https://<id>.ngrok.io URL in the dashboard as your webhook URL
# or Cloudflare Tunnel
cloudflared tunnel --url http://localhost:3000To sanity-check your endpoint without waiting for a real event, simulate a delivery from your machine. just make sure the signature is valid if your handler enforces verification:
# Compute the expected signature for a test payload
BODY='{"id":"21096b94-498f-4a2d-9795-e810edc2c9a9","type":"document.created","createdAt":"2026-04-21T10:30:00.000Z","data":{"documentId":"test-123"}}'
TS="2026-04-21T10:30:00.000Z"
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$CHAINDOC_WEBHOOK_SECRET" | cut -d' ' -f2)
curl -X POST http://localhost:3000/webhooks/chaindoc \
-H 'Content-Type: application/json' \
-H "X-Chaindoc-Event: document.created" \
-H "X-Chaindoc-Delivery-Id: 21096b94-498f-4a2d-9795-e810edc2c9a9" \
-H "X-Chaindoc-Timestamp: $TS" \
-H "X-Chaindoc-Signature: v1=$SIG" \
-d "$BODY"Best practices
- Idempotency. Use
idfrom the envelope (or theX-Chaindoc-Delivery-Idheader) as a dedupe key. retries re-send the same delivery. - Process asynchronously. Respond 2xx immediately and hand off to a queue (BullMQ, SQS, Cloud Tasks). Keeps your p99 under 30s.
- Log everything. Persist the full envelope. useful for debugging, audit, and replaying failed handlers.
- Handle unknown events gracefully. New event types may ship at any time. Route on
type; don't throw on unrecognized values, just ignore. - Alert on sustained failures. Poll your webhook logs / success rate. if deliveries keep failing, the key will eventually get auto-disabled and you'll miss events entirely.
Production example
A production-ready handler with verification via the Server SDK, idempotency by delivery ID, persistence, and async processing:
import express from 'express';
import { Chaindoc } from '@chaindoc_io/server-sdk';
import { PrismaClient } from '@prisma/client';
const app = express();
const prisma = new PrismaClient();
app.post(
'/webhooks/chaindoc',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.header('X-Chaindoc-Signature') ?? '';
const timestamp = req.header('X-Chaindoc-Timestamp') ?? '';
const rawBody = (req.body as Buffer).toString('utf8');
// 1. Verify signature (HMAC + 5-minute replay window)
const result = Chaindoc.webhooks.verify(
rawBody,
signature,
timestamp,
process.env.CHAINDOC_WEBHOOK_SECRET!,
);
if (!result.valid || !result.envelope) {
return res.status(401).send('Invalid signature');
}
const { id, type, data } = result.envelope;
// 2. Idempotency. dedupe by envelope id
const seen = await prisma.webhookDelivery.findUnique({ where: { id } });
if (seen) {
return res.status(200).json({ status: 'duplicate' });
}
// 3. Persist, respond, then process asynchronously
await prisma.webhookDelivery.create({ data: { id, type, payload: data } });
res.status(200).json({ received: true });
processAsync(type, data).catch((err) =>
console.error('Webhook handler error', { id, type, err }),
);
},
);
async function processAsync(type: string, data: any) {
switch (type) {
case 'document.created':
await prisma.document.update({
where: { id: data.documentId },
data: { chaindocStatus: 'created' },
});
break;
case 'contract.signed':
await prisma.contract.update({
where: { id: data.contractId },
data: { status: data.status ?? 'active', signedAt: new Date() },
});
break;
case 'invoice.paid':
await prisma.invoice.update({
where: { id: data.invoiceId },
data: { status: 'paid', paidAt: new Date(data.paidAt) },
});
break;
// route other event types here
}
}
app.listen(3000);What to do next
- API integration. common patterns and workflow examples
- API documentation. full endpoint reference
- SDKs. Server SDK (includes
Chaindoc.webhooks.verify) and Embed SDK - Security. API key management and production hardening
- Installation. SDK setup for all frameworks
Frequently asked questions
Quick answers to the questions developers ask most often.