Webhooks
Webhooks allow your application to receive real-time notifications when events occur in APISign. Instead of repeatedly polling our API, webhooks push event data to your application immediately when something happens.
https://apisign.io/apiEvery endpoint on this page is authenticated with your API key in the x-api-key header, and every one of them takes and returns JSON. REST routes do not check the key's permission, so a Read Only key can create, change, and delete webhooks too.
Webhook Endpoints
Delete is a POST
/webhook/delete takes the id in a JSON body over POST, not as a query parameter over DELETE. The same is true of /webhook/update, /webhook/test, and /webhook/rotate-secret.
Available Events
Event names use underscores. Any other spelling is rejected by /webhook/create and /webhook/update with a 400.
| Event | Description |
|---|---|
contract_created | A contract was created through /contract/create or the dashboard. Contracts created with the MCP contract_create tool do not emit it. |
contract_sent | Contract was sent to signers |
contract_resent | A contract already in sent status was sent again |
contract_viewed | A signer opened their signing link. Fires every time the link is opened, not once per signer. |
contract_signed | A signer completed their signature |
contract_completed | All parties have signed the contract. Fires right after the last signer's contract_signed. |
contract_declined | A signer declined to sign |
contract_expired | A sent contract passed its expires_at — see below |
contract_cancelled | Contract was cancelled through the API, dashboard, or MCP. A contract that becomes cancelled because a signer declined emits only contract_declined. |
That is the complete list. There are no template events.
Don't depend on contract_expired
contract_expired is not emitted when the deadline passes. A separate scheduled job looks for sent contracts whose expires_at has passed, sets their status to expired and emits the event, so it can arrive well after the deadline. When it fires, data.signer is null and data.metadata carries expires_at and expired_by: "system_cron". The signing link stops working at expires_at whether or not the job has run, so treat expires_at from /contract/get as the deadline.
Webhook Payload
Every delivery is a POST with this body:
{
"id": "clx8k2q1b0002qz3f6j0e3c8f",
"event": "contract_signed",
"created_at": "2026-07-28T14:30:00.000Z",
"data": {
"organization_id": "clx8k1m4z0000qz3f7g2c1a9d",
"contract": {
"id": "clx8k2n7b0003qz3f8j4e3c1f",
"name": "Service Agreement",
"status": "sent",
"sent_at": "2026-07-28T14:02:11.000Z",
"test_mode": false
},
"signer": {
"id": "clx8k2o9c0004qz3f1k5f4d2g",
"name": "Jordan Lee",
"email": "jordan@acme.com",
"status": "signed",
"signed_at": "2026-07-28T14:30:00.000Z"
},
"metadata": {}
}
}
| Field | Notes |
|---|---|
id | The audit log entry that produced this event. Every webhook subscribed to the event gets the same id, and it is stable across retries — use it for idempotency. |
event | The event type, in the underscore form above. There is no type field. |
created_at | ISO 8601 string, not a Unix timestamp. Set when the event was recorded; retries resend the same value. |
data.contract | null for events with no contract context. status is the contract's status when the event was recorded, and retries resend that snapshot unchanged. |
data.signer | null for events with no signer context: contract_created, contract_sent, contract_resent, contract_completed, contract_expired, contract_cancelled. |
data.metadata | Event-specific extras, such as fieldValues on contract_signed or declineReason on contract_declined. May be absent entirely. |
A completed contract has status signed
When every signer has signed, the contract's status becomes signed. There is no completed status — contract_completed is the name of the event, not of the state.
Webhook Headers
Every delivery carries these headers, plus any custom headers you set on the webhook. Custom headers are applied last, so one with the same name replaces the header below.
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | t=<unix-seconds>,v1=<hex-hmac> — see below |
X-Webhook-Timestamp | The same Unix timestamp, as a string |
X-Webhook-ID | ID of the delivery — one event to one webhook. It stays the same across retries of that delivery, and matches the id in /webhook/get and /webhook/logs. |
The event type is not in a header. Read it from event in the body. The timestamp and signature are recomputed on every attempt, so a retry carries a fresh t.
Signature Verification
Webhook Security
Always verify webhook signatures to ensure requests are genuine. Sign the raw request body — not a re-serialized copy of the parsed JSON, which will not match byte for byte.
X-Webhook-Signature has the form t=1753712345,v1=9f86d081.... To verify:
- Split the header on
,and readt=andv1=. - Reject the request if
tis more than 5 minutes from now. - Compute
HMAC-SHA256(key = your webhook secret, message = "<t>.<raw body>")and hex-encode it. - Compare that to
v1with a constant-time comparison.
The key is the whsec_... secret exactly as returned by /webhook/create, used as a UTF-8 string.
Node.js Example
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
// Header format: t=<unix seconds>,v1=<hex hmac>
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i), p.slice(i + 1)];
})
);
const timestamp = parts.t;
const received = parts.v1;
if (!timestamp || !received) return false;
// Reject replays older than 5 minutes
if (Math.abs(Date.now() / 1000 - parseInt(timestamp, 10)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`, 'utf8')
.digest('hex');
// timingSafeEqual throws on length mismatch, so check length first
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
// Usage in Express.js — note express.raw, so rawBody is the exact bytes we signed
app.post('/webhooks/apisign', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(rawBody, req.get('X-Webhook-Signature'), process.env.APISIGN_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
switch (event.event) {
case 'contract_signed':
// one signer finished
break;
case 'contract_completed':
// everyone finished; contract.status is now "signed"
break;
}
res.status(200).send('OK');
});
Python Example
import hmac
import hashlib
import time
def verify_webhook_signature(raw_body: str, signature_header: str, secret: str) -> bool:
# Header format: t=<unix seconds>,v1=<hex hmac>
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts.get("t")
received = parts.get("v1")
if not timestamp or not received:
return False
# Reject replays older than 5 minutes
if abs(time.time() - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.{raw_body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(received, expected)
Delivery and Retries
A delivery is attempted as soon as the event is recorded. A delivery counts as failed if the response status is not 2xx, the connection cannot be established, the TLS handshake fails, or no response arrives within 30 seconds.
Each delivery gets up to 5 attempts. The backoff after each failed attempt is:
| After attempt | Next attempt scheduled in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | No further attempts — the delivery is marked failed |
A failure counter runs across deliveries, incrementing on every failed attempt and resetting to 0 on the first success. When it reaches 10, the webhook's status becomes failed and no new events are queued for it — so two fully exhausted deliveries are enough to disable an endpoint. Set status back to active with /webhook/update to re-enable it; that also resets the counter. Events that occurred while it was failed or inactive are not replayed.
Retries are not on a timer yet
No background scheduler processes retries. Due retries are picked up only when your organization produces another event that one of its active webhooks subscribes to; that pass works through up to 100 due deliveries across all of the organization's webhooks, including ones since set to failed or inactive. If your organization goes quiet, a failed delivery waits past its scheduled time, possibly indefinitely. Treat webhooks as best-effort and reconcile against /contract/get when correctness matters. Delivery history, including every failed attempt, is available from /webhook/get, /webhook/logs, and the dashboard.
Best Practices
Respond Quickly
Return a 2xx response immediately, then process the event asynchronously:
app.post('/webhooks/apisign', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhookSignature(rawBody, req.get('X-Webhook-Signature'), secret)) {
return res.status(401).send('Invalid signature');
}
webhookQueue.add('process-event', JSON.parse(rawBody));
res.status(200).send('OK');
});
Handle Duplicates
A retried delivery repeats the same payload, so dedupe on the payload's id:
function handleWebhookEvent(event) {
if (alreadyProcessed(event.id)) return;
processEvent(event);
markProcessed(event.id);
}
Use HTTPS
We do not reject http:// endpoint URLs, but you should not use one. The payload contains signer names and email addresses, and the signature only proves the request came from us — it does not encrypt anything.
Testing Webhooks
Send a test delivery
POST /webhook/test with {"id": "<webhook id>"} sends a synthetic contract_signed delivery to your URL and returns the status code, body, and duration it got back, along with the exact payload and headers it sent. The same thing is available in the dashboard under Account → Webhooks.
Test deliveries are signed with the webhook's secret like real ones, but the payload carries "test": true, the request carries an extra X-Webhook-Test: true header, and both the payload id and X-Webhook-ID are test_<milliseconds>. A test is sent even when the webhook is inactive or failed, is attempted once with no retries, is not recorded in your delivery history, and does not touch the failure counter.
{
"id": "test_1753712345678",
"event": "contract_signed",
"created_at": "2026-07-28T14:30:00.000Z",
"test": true,
"data": {
"organization_id": "clx8k1m4z0000qz3f7g2c1a9d",
"contract": { "id": "test_contract_id", "name": "Test Contract", "status": "signed", "test_mode": true },
"signer": { "id": "test_signer_id", "name": "Test Signer", "email": "test@example.com", "status": "signed" },
"metadata": { "message": "This is a test webhook delivery" }
}
}
Local Development
Use ngrok to expose your local server:
ngrok http 3000
# Use the HTTPS URL: https://abc123.ngrok.io/webhooks/apisign