Webhooks
Webhooks allow your application to receive real-time notifications when events occur in API Sign. 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.
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 and /webhook/test.
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 new contract was created |
contract_sent | Contract was sent to signers |
contract_resent | Contract was resent to signers |
contract_viewed | A signer opened the contract |
contract_signed | A signer completed their signature |
contract_completed | All parties have signed the contract |
contract_declined | A signer declined to sign |
contract_expired | Contract passed its expiration date |
contract_cancelled | Contract was cancelled |
That is the complete list. There are no template events.
contract_expired is not emitted yet
You can subscribe to contract_expired and the subscription is stored, but nothing currently marks a contract as expired in production, so the event never fires. Don't build on it yet. If you need expiry behaviour today, compare expires_at from /contract/get against the current time in your own code.
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. Stable across retries of the same event — 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. |
data.contract | null for events with no contract context. status is the contract's status at send time. |
data.signer | null for events with no signer context, such as contract_created. |
data.metadata | Event-specific extras. 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:
| Header | Description |
|---|---|
Content-Type | Always 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 | Identifier for this delivery attempt, not for the webhook |
The event type is not in a header. Read it from event in the body.
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 the request does not complete 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 it stops receiving events entirely — 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.
Retries are not on a timer yet
Scheduled retries are processed when your organization produces its next webhook event, not by a background scheduler. If your organization goes quiet, a failed delivery can wait past its scheduled time. Treat webhooks as best-effort and reconcile against /contract/get when correctness matters. Delivery history, including every failed attempt, is available from /webhook/get and in 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 differ from real ones in two ways: the payload carries "test": true, and the request carries an extra X-Webhook-Test: true header. They are not recorded in your delivery history.
{
"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