Webhooks
Receive signed HTTP notifications when envelopes are sent, viewed, signed, declined or voided — and verify them with your endpoint secret.
On this page
Webhooks push changes to your server the moment they happen: an envelope is sent, a recipient opens or signs it, someone declines, the envelope completes. Each delivery is an HTTPS POST with a JSON body, signed with your endpoint’s secret.
Set up an endpoint
- Create an endpoint in Developer → Webhooks or with POST/v1/webhooks: a public HTTPS URL and the events you want. Private, loopback and link-local addresses are refused, and redirects are not followed. An account can have up to 20 endpoints.
- Save the signing secret (
whsec_…). The API returns it only on create and on POST/v1/webhooks/{id}/rotate-secret. - Send a signed
pingwith POST/v1/webhooks/{id}/test and check the delivery in GET/v1/webhooks/{id}/deliveries.
Events
GET/v1/webhook-events returns the same list, so your integration can discover new events.
Payload
POST /webhooks/signkoi HTTP/1.1
Content-Type: application/json
User-Agent: Signkoi-Webhooks/1.0
Signkoi-Event: recipient.completed
Signkoi-Delivery: a2c5e8f1-6b3d-4d9a-8c7e-1f4b0a9d3e62
Signkoi-Signature: t=1794652960,v1=5f2b9c0e8a7d6f4b3c1e0d9a8b7c6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d{
"id": "evt_01jcz8q6x4m7r2t9v3w5y8b1d4",
"event": "recipient.completed",
"api_version": "2026-11-14",
"created_at": "2026-11-15T14:02:40+00:00",
"data": {
"document": {
"id": "9b2f4c1e-7a3d-4e8b-9c51-2d6f8a0b3e47",
"title": "Consulting agreement — Acme Ltd",
"status": "awaiting_signature",
"metadata": {
"crm_deal_id": "D-10492"
}
},
"recipient": {
"id": "c4e1a8f2-3b7d-4f19-a6c2-8e5d0b9f1a73",
"name": "Maya Patel",
"email": "maya@example.com",
"role": "signer",
"status": "completed"
},
"envelope": {
"object": "envelope",
"id": "9b2f4c1e-7a3d-4e8b-9c51-2d6f8a0b3e47",
"title": "Consulting agreement — Acme Ltd",
"status": "sent",
"message": {
"subject": "Please sign: Consulting agreement",
"body": "Hi Maya, here is the agreement we discussed."
},
"metadata": {
"crm_deal_id": "D-10492"
},
"template_id": null,
"source": "api",
"page_count": 3,
"reminders": {
"delay_days": 2,
"frequency_days": 3
},
"recipients": [
{
"object": "recipient",
"id": "c4e1a8f2-3b7d-4f19-a6c2-8e5d0b9f1a73",
"role": "signer",
"role_name": null,
"name": "Maya Patel",
"email": "maya@example.com",
"routing_order": 1,
"status": "completed",
"language": "en",
"verification": "email_otp",
"phone": null,
"sent_at": "2026-11-14T09:30:12Z",
"viewed_at": "2026-11-15T13:58:01Z",
"completed_at": "2026-11-15T14:02:40Z",
"declined_at": null,
"decline_reason": null
},
{
"object": "recipient",
"id": "e7b3d915-0c4a-4b6e-8f27-5a1c9d3e6b08",
"role": "cc",
"role_name": null,
"name": "Jonas Weber",
"email": "jonas@example.com",
"routing_order": 2,
"status": "sent",
"language": "de",
"verification": "none",
"phone": null,
"sent_at": "2026-11-15T14:02:41Z",
"viewed_at": null,
"completed_at": null,
"declined_at": null,
"decline_reason": null
}
],
"files": {
"original": "https://signkoi.com/api/v1/envelopes/9b2f4c1e-7a3d-4e8b-9c51-2d6f8a0b3e47/files/original",
"signed": null,
"certificate": null
},
"created_at": "2026-11-14T09:30:05Z",
"sent_at": "2026-11-14T09:30:12Z",
"completed_at": null,
"declined_at": null,
"voided_at": null,
"void_reason": null,
"expires_at": "2026-12-14T09:30:12Z"
}
}
}Use data.envelope for statuses
For backward compatibility, data.document.status and data.recipient.status use internal names (awaiting_signature, signed, delivered, …). Read statuses from data.envelope, which uses the public names (sent, completed, viewed, …), and match the recipient by id in data.envelope.recipients.
The test event looks like this:
{
"id": "evt_01jcz9b2k7n4p6r8t0v2x4z6a8",
"event": "ping",
"api_version": "2026-11-14",
"created_at": "2026-11-14T10:05:00+00:00",
"data": {
"webhook": { "id": "6d1e9f3a-4b2c-4a87-9e5d-0f7b3c8a2d54", "events": ["envelope.completed"] },
"message": "Hello from Signkoi. Your webhook endpoint is reachable."
}
}Verifying signatures
Every delivery has a Signkoi-Signature header: t=<unix timestamp>,v1=<signature>. v1 is the lowercase hex HMAC-SHA256 of the string "{t}.{raw body}", keyed with your endpoint secret (the whole string, including whsec_). To verify:
- Read the raw request body, byte for byte. Re-serialising parsed JSON changes it and the check fails.
- Split the header on
,and=to gettandv1. - Reject the request if
tis more than 5 minutes from your clock — this stops replays. - Compute HMAC-SHA256 of
t + "." + bodywith the secret and compare it withv1in constant time. - Only then parse the JSON and act on it. Answer 400 for a bad signature.
Verification code
import crypto from 'node:crypto';
import express from 'express';
/** True when `header` is a valid Signkoi-Signature for the raw body. */
export function verifySignkoiSignature(rawBody, header, secret, tolerance = 300) {
if (typeof header !== 'string') return false;
const parts = {};
for (const pair of header.split(',')) {
const i = pair.indexOf('=');
if (i > 0) parts[pair.slice(0, i).trim()] = pair.slice(i + 1).trim();
}
const { t, v1 } = parts;
if (!/^\d+$/.test(t ?? '') || !/^[0-9a-f]{64}$/.test(v1 ?? '')) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > tolerance) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}
const app = express();
// express.raw keeps the body as a Buffer — verify before parsing JSON.
app.post('/webhooks/signkoi', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignkoiSignature(req.body, req.get('Signkoi-Signature'), process.env.SIGNKOI_WEBHOOK_SECRET)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
queue.add(event); // do the work later; answer within 10 seconds
res.sendStatus(200);
});Rotating the secret
POST/v1/webhooks/{id}/rotate-secret switches to a new secret immediately. Deploy the new secret to your receiver right away; deliveries signed with the old one won’t verify any more.
Retries and auto-disable
A delivery succeeds when your endpoint answers with any 2xx status within 10 seconds. Anything else — another status, a redirect, a timeout or a connection error — is a failure, and Signkoi retries up to five more times:
- Each delivery’s
statusispending,delivered,retrying(withnext_retry_at) orfailed;response_statusand the first 1,000 characters of your response help you debug. - Retries reuse the same
Signkoi-Deliveryid and eventid, with a fresh timestamp and signature. - After 20 consecutive failed attempts the endpoint is switched off (
enabled: false). Fix your receiver, then turn it back on with PATCH/v1/webhooks/{id} ("enabled": trueresets the failure count) or on the dashboard.
Best practices
- Answer quickly with 200 and do the work in a background job.
- Make handlers idempotent: store processed event
ids and skip repeats. - Don’t rely on order. Events can arrive out of sequence; when in doubt, fetch the envelope with GET/v1/envelopes/{id}.
- Ignore event types you don’t know, so new events never break your receiver.
- On
envelope.completed, download the signed PDF and certificate from thefilesURLs.