Webhooks

Both signature schemes, the full event catalog, health states, retry schedule, the delivery log, and replay.

Updated

On this page

Webhooks are how you find out what happened without polling. Signclad’s are built to be operated: you can tell when an endpoint is failing, see every attempt, and replay anything you missed.

Creating an endpoint

curl -X POST https://api.signclad.com/api/v1/webhook_endpoints \
  -H "X-Api-Key: $SIGNCLAD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/webhooks/signclad",
    "description": "Production receiver",
    "event_types": ["document_completed", "document_declined", "document_error"],
    "include_test_mode": false
  }'

The same call from Node and Python:

const response = await fetch('https://api.signclad.com/api/v1/webhook_endpoints', {
  method: 'POST',
  headers: {
    'X-Api-Key': process.env.SIGNCLAD_API_KEY,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://app.example.com/webhooks/signclad',
    description: 'Production receiver',
    event_types: ['document_completed', 'document_declined', 'document_error'],
    include_test_mode: false,
  }),
});

if (!response.ok) throw new Error(`${response.status}: ${await response.text()}`);
const endpoint = await response.json();
// endpoint.secret is returned exactly once. Store it now.
import os

import requests

response = requests.post(
    "https://api.signclad.com/api/v1/webhook_endpoints",
    headers={"X-Api-Key": os.environ["SIGNCLAD_API_KEY"]},
    json={
        "url": "https://app.example.com/webhooks/signclad",
        "description": "Production receiver",
        "event_types": ["document_completed", "document_declined", "document_error"],
        "include_test_mode": False,
    },
    timeout=30,
)
response.raise_for_status()
endpoint = response.json()  # endpoint["secret"] is returned exactly once.

The secret is shown once. Store it. If your receiver already has a signing key in its configuration, send your own as secret instead — at least 32 characters — and skip the deploy that would otherwise exist only to learn ours.

The endpoint is active immediately and no ping is sent. Add verify: true and it goes the other way: the endpoint is created paused, one ping is delivered, and it activates only if that lands. The ping’s result comes back on the create response as ping.

Omit event_types to subscribe to everything, now and later; ["*"] means the same thing, and events is accepted as a spelling of the same field. Add api_application_id to receive only events for documents created by one application.

The payload

{
  "event": {
    "id": "evt_01J9K3P2S5N7Q8R1T2V3W4X5",
    "hash": "6f4a9c2e8b1d3f5a7c9e0b2d4f6a8c1e3b5d7f9a0c2e4b6d8f0a2c4e6b8d0f2a",
    "time": 1789050255,
    "type": "document_completed",
    "sequence": 9,
    "related_signer": { "email": "[email protected]", "name": "Jane Doe" }
  },
  "data": {
    "account_id": "9e0f1a2b-3c4d-5e6f-7a8b-9c0d1e2f3a4b",
    "workspace_id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
    "object": {
      "id": "8f14e45f-ea8f-4b2c-9f0e-1b2c3d4e5f60",
      "status": "Completed",
      "name": "Loan agreement",
      "completed_at": "2026-09-09T17:44:15Z",
      "completed_pdf_url": null,
      "audit_trail_url": null,
      "recipients": [
        { "id": "signer-1", "name": "Jane Doe", "email": "[email protected]",
          "status": "completed", "signed_at": "2026-09-09T17:44:12Z", "bounced": false }
      ]
    }
  }
}

data.object is the same body GET /documents/{id} returns, plus workspace_id and account_id. That means the artifact URLs are null here, as they are everywhere else: a completed document’s PDF comes from GET /documents/{id}/completed_pdf, and its audit trail from the same path with ?audit_only=true. Fetch them when document_completed arrives rather than looking for a link in the payload.

Payloads are snapshots taken when the event happened. They never change, on a retry or a replay.

Headers

Header Value
X-Signclad-Signature t=<unix>,v1=<hex hmac-sha256(secret, "<t>.<raw body>")>
X-Signclad-Event The event type.
X-Signclad-Event-Id The stable event.id. Identical across retries and replays. Dedupe on this.
X-Signclad-Delivery Delivery id, fresh per attempt.
X-Signclad-Attempt 1-based attempt number.

Verifying: two schemes

Body signature (use this)

import crypto from 'node:crypto';

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((p) => p.split('=').map((s) => s.trim()))
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // The header can carry several v1= values during a secret rotation.
  return header
    .split(',')
    .filter((p) => p.trim().startsWith('v1='))
    .some((p) => {
      const given = Buffer.from(p.trim().slice(3), 'hex');
      const want = Buffer.from(expected, 'hex');
      return given.length === want.length && crypto.timingSafeEqual(given, want);
    });
}

The same check in Python, against the raw request body your framework received:

import hashlib
import hmac
import time


def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [piece.strip() for piece in header.split(",")]
    stamps = [piece[2:] for piece in parts if piece.startswith("t=")]
    if not stamps or not stamps[0].isdigit():
        return False
    timestamp = int(stamps[0])
    if abs(time.time() - timestamp) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    # The header can carry several v1= values during a secret rotation.
    return any(
        hmac.compare_digest(piece[3:], expected)
        for piece in parts
        if piece.startswith("v1=")
    )

Verify against the raw body, before any JSON parsing or middleware re-serialisation. Compare in constant time. The tolerance is five minutes.

During a secret rotation the header carries one v1= value per valid secret, so rotation needs no cutover on your side.

Compatibility hash (legacy)

Endpoints registered through the compatibility POST /hooks route also include a legacy event hash:

event.hash === hex(HMAC-SHA256(key = <hook id>, data = "<event.type>@<event.time>"))

This is computed over the header line, not the body, so it does not attest to the payload you received. It exists so an existing integration keeps working unchanged. Move to the body signature when you can.

The event catalog

Every event Signclad emits — with what makes it fire, how often, what arrives in data.object, and whether it is wire-compatible or a Signclad addition — is on its own page, generated from the OpenAPI document at build time so it cannot drift:

Webhook events →

Three cadences catch people out, so they are worth repeating here:

  • document_viewed fires on every view, not just the first.
  • document_in_progress fires once, the first time any value is saved.
  • document_signed fires per recipient, so a two-signer document emits it twice.

Delivery, retry, and ordering

  • At least once. 10-second timeout. Any 2xx counts as delivered.
  • Retries at 1 m, 5 m, 15 m, 1 h, 3 h, 8 h, 24 h, 48 h with jitter of ±10% — nine attempts in all, the first plus eight retries, over about three and a half days.
  • Retry-After is honoured in one direction only: it can push the next attempt out, up to the 48-hour ceiling, and it can never pull one in ahead of the schedule. A 410 pauses the endpoint immediately, keeping the events replayable rather than dropping them.
  • Events are dispatched in per-document sequence order, but a failing delivery never blocks later events. Each retries independently.

So: dedupe on event.id, and where order matters, sort by event.sequence within a document rather than assuming arrival order.

Respond quickly and process asynchronously. A receiver that does its work before answering will hit the 10-second timeout and be retried, which is how duplicate processing happens.

Health states

State Trigger What happens
healthy Deliveries land. Nothing.
degraded Failures for 15 minutes without a success (at least 3 in a row), or over 25% failures in an hour (minimum 20 deliveries) while the latest attempt is still failing. Email to workspace admins, in-app banner, daily digest while it persists.
failing 25 consecutive failures, or 100% over 24 hours. Circuit breaker stops attempting and probes hourly.
disabled 5 days of continuous failure. Endpoint switched off, with a notice and one-click re-enable that offers to replay everything missed.

A brief blip on your side, such as a burst of 503s while you deploy, does not raise an alert: those deliveries are retried anyway. An endpoint leaves degraded once the latest attempt succeeds and the last hour is back under 25% failures, so one that fails intermittently stays degraded instead of flapping, and recovery after a busy outage can take up to an hour.

GET /webhook_endpoints/{id}/health
{ "state": "healthy", "consecutive_failures": 0,
  "success_rate_1h": 1, "success_rate_24h": 0.998, "p95_latency_ms": 210,
  "last_success_at": "2026-09-09T17:43:58Z",
  "last_failure_at": "2026-09-08T22:11:04Z",
  "last_failure_reason": "502 Bad Gateway", "pending_count": 0 }

The delivery log

GET /webhook_endpoints/{id}/deliveries?status=failed&cursor=...

Every delivery with its attempt timeline, the request headers and body, the response status and a body excerpt, and the latency of each attempt. Filter by event type, status, or document. Dead letters are kept 30 days.

Replay

POST /webhook_endpoints/{id}/deliveries/{delivery_id}/replay

Replay one delivery, a selection, or a date range, or resend everything that failed since a moment you choose. On a replay event.id, event.time, and the payload are unchanged; the signature timestamp, X-Signclad-Delivery, and X-Signclad-Attempt are fresh.

Rotating a secret

POST /webhook_endpoints/{id}/rotate_secret

Both secrets are valid during the overlap and the signature header carries a v1= value for each. Deploy the new secret, confirm deliveries verify, then end the overlap.

Debugging before you have a receiver

Every workspace has a hosted inbox URL that records requests. Point an endpoint at it and look at real payloads before writing a line of code.