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:
Three cadences catch people out, so they are worth repeating here:
document_viewedfires on every view, not just the first.document_in_progressfires once, the first time any value is saved.document_signedfires per recipient, so a two-signer document emits it twice.
Delivery, retry, and ordering
- At least once. 10-second timeout. Any
2xxcounts 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-Afteris 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. A410pauses the endpoint immediately, keeping the events replayable rather than dropping them.- Events are dispatched in per-document
sequenceorder, 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.
Related
- Webhook events — the generated catalog, with cadence and compatibility per event.
- Errors and rate limits
- Sandbox and test mode
- Embedded builders and the compatibility shim — why browser messages are hints and these are the record.