ChaindocLabs

Last updated: April 28, 2026

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/json
  • User-Agent: Chaindoc-Webhook/1.0
  • X-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 form v1=<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:

json
{
  "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 to X-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 (data includes txHash and chainId).
  • 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 in draft status.
  • contract.status_changed. a contract transitions between lifecycle statuses (draftpending_signature, pending_signatureactive, etc.).
  • contract.signed. both parties have signed a contract.
  • contract.cancelled. a draft or pending_signature contract is cancelled.
  • contract.terminated. an active contract is terminated (for mutual_approval contracts, 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 manual mark-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. PENDINGSUCCESS / 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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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

json
{
  "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:

bash
# 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:3000

To 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:

bash
# 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 id from the envelope (or the X-Chaindoc-Delivery-Id header) 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:

webhooks/chaindoc.ts
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

Frequently asked questions

Quick answers to the questions developers ask most often.

What events trigger webhooks?
Chaindoc sends webhooks for document signed, declined, viewed, completed, contract created, invoice paid, and transaction confirmed events.
How do I verify webhook signatures?
Each webhook includes an X-Chaindoc-Signature header with an HMAC-SHA256 digest. Compute the digest using your webhook secret and compare it to the header value.
What is the retry policy?
Failed webhooks are retried up to five times with exponential backoff: 5 seconds, 25 seconds, 2 minutes, 10 minutes, and 1 hour.
Can I test webhooks locally?
Yes. Use tools like ngrok or webhook.site to expose a local endpoint. Add the temporary URL in your Chaindoc webhook settings.
How do I debug failed deliveries?
Check the webhook delivery logs in your Chaindoc dashboard. Each attempt shows the request body, response status, and response body.
Can I filter which events I receive?
Yes. When creating a webhook subscription, select the specific event types you want. You can create multiple subscriptions with different filters.