Browse developers
Start here
Working with the API
Receiving events
Reference
- Developers
- Receiving events
Webhooks
Every event we send, what each payload contains, and how to verify that a callback came from us.
Last updated
A webhook is how you find out that a client opened a document, signed it, declined it or let it lapse, without asking us every thirty seconds. Register an HTTPS endpoint, choose the events you care about, verify the signature on each delivery, and answer quickly.
Register an endpoint
curl -X POST https://cecursign.io/api/v1/webhooks \
-H "Authorization: Bearer cs_prod_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/cecursign",
"events": ["ENVELOPE_COMPLETED", "ENVELOPE_DECLINED", "ENVELOPE_VOIDED"]
}'
{
"id": "c4a1e0d2-77b3-4f61-9a0c-2e5d8b1f3a44",
"url": "https://api.example.com/webhooks/cecursign",
"events": ["ENVELOPE_COMPLETED", "ENVELOPE_DECLINED", "ENVELOPE_VOIDED"],
"secret": "whsec_2f0b...",
"isActive": true
}
Store secret now. It is whsec_ followed by 64 hexadecimal characters, and it is a
credential: anyone holding it can forge a delivery that your receiver will accept. It is returned
by exactly two requests, the one that creates the endpoint and
POST /v1/webhooks/{id}/regenerate-secret, and never by a read. If you lose it, regenerate it and
deploy the new one.
The URL must be an https address that resolves to a public host. A subscription covers the whole
account, so one endpoint hears about every envelope in it.
You can also manage endpoints in the application, at Settings > Developer > Webhooks. Over the
API, GET /v1/webhooks and GET /v1/webhooks/{id} need READ; creating, updating, deleting,
regenerating the secret, sending a test delivery and retrying a delivery need WRITE.
What a delivery looks like
POST /webhooks/cecursign HTTP/1.1
Content-Type: application/json
X-Cecur-Event: ENVELOPE_COMPLETED
X-Cecur-Event-Id: 8f14e45f-ceea-4e7a-9e1b-6b8a7c2d3e4f
X-Cecur-Delivery: 6d2f9c11-3a4b-4c5d-8e9f-0a1b2c3d4e5f
X-Cecur-Signature: t=1787654321,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
User-Agent: CecurSign-Webhook/1.0
{
"eventId": "8f14e45f-ceea-4e7a-9e1b-6b8a7c2d3e4f",
"event": "ENVELOPE_COMPLETED",
"timestamp": "2026-08-26T14:02:11.183Z",
"data": {
"envelopeId": "3b7d0e11-4c22-4f8a-9a1e-8d2c4b5e6f70",
"subject": "Please sign: engagement letter",
"completedAt": "2026-08-26T14:02:11.140Z",
"totalSigners": 2
}
}
Every delivery has that four-field envelope: the event's id, the event name, an ISO 8601
timestamp, and a data object whose shape depends on the event.
eventId identifies the event, and it is also sent as X-Cecur-Event-Id. It is the same on
every retry, on a delivery you send again from the dead-letter list, and on every endpoint the
event reaches, so if two of your endpoints subscribe to the same event they receive the same id.
That makes it the key to deduplicate on. X-Cecur-Delivery identifies one delivery of an event to
one endpoint, and is also stable across that delivery's retries.
Delivery is at least once. An event is recorded at the moment the change it describes is
committed, so it is never dropped between the two, and it is retried until your endpoint answers
2xx or the attempts run out. The same event can therefore reach you more than once: when your
endpoint handled it but we did not receive its answer, for instance. Handle a repeated eventId as
a delivery you have already processed.
Verify the signature
The signature is an HMAC-SHA-256, in hexadecimal, over the exact string
<unix-seconds>.<raw request body>, keyed with the endpoint's secret. The header carries the
timestamp it was computed with:
X-Cecur-Signature: t=<unix-seconds>,v1=<hex>
Sign the raw bytes we sent, not a re-serialised object. Parsing JSON and stringifying it again changes key order and whitespace, and the digest will not match.
const crypto = require('crypto');
app.post(
'/webhooks/cecursign',
express.raw({ type: 'application/json' }),
(req, res) => {
const parts = Object.fromEntries(
(req.get('X-Cecur-Signature') || '').split(',').map((p) => p.split('=')),
);
const expected = crypto
.createHmac('sha256', process.env.CECURSIGN_WEBHOOK_SECRET)
.update(parts.t + '.' + req.body.toString('utf8'))
.digest('hex');
const given = Buffer.from(parts.v1 || '', 'hex');
const mine = Buffer.from(expected, 'hex');
if (given.length !== mine.length || !crypto.timingSafeEqual(given, mine)) {
return res.status(400).send('bad signature');
}
// Bound how long a captured delivery stays replayable.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
return res.status(400).send('stale timestamp');
}
res.status(200).end();
enqueue(JSON.parse(req.body.toString('utf8')));
},
);
Two details worth keeping: compare with a constant-time comparison such as timingSafeEqual
rather than ===, and check t against your own clock so that a delivery captured in transit
cannot be replayed at you indefinitely. Five minutes is a reasonable window.
The t in the header is the moment the delivery attempt was signed. The timestamp in the body is
the moment the event happened. They are close but not identical, and it is t that the signature
is computed over.
The events
| Event | Fires when | data |
|---|---|---|
ENVELOPE_SENT | An envelope goes out to its recipients | envelopeId, subject, sentAt, recipientCount, signerCount |
ENVELOPE_VIEWED | A recipient opens the envelope for the first time | envelopeId, recipientId, recipientEmail, recipientName, viewedAt |
RECIPIENT_COMPLETED | A signer completes their part | envelopeId, recipientId, recipientEmail, recipientName, signedAt |
ENVELOPE_COMPLETED | Every signer has signed | envelopeId, subject, completedAt, totalSigners |
ENVELOPE_DECLINED | A recipient declines to sign | envelopeId, subject, declinedBy, declinedByName, reason, declinedAt |
ENVELOPE_VOIDED | The sender withdraws the envelope | envelopeId, subject, voidedAt, voidReason |
ENVELOPE_EXPIRED | The expiry date passes with signatures outstanding | envelopeId, subject, expiresAt |
ENVELOPE_COMPLETED is the one to hang your business logic on. It is the point at which the
document is finished and the certificate of completion exists.
A document the sender signed alone raises none of these. Nothing was sent to anybody, so a
SELF_SIGN envelope raises no webhook at any point: not when its signer opens it, not when they
sign it, not when it completes, and not when it is voided. It cannot be reminded either, so
POST /v1/envelopes/{id}/remind answers 409 for one.
Envelope responses carry source, so an envelope id from any of these events resolves to its
origin with one GET. Handle a value you do not recognise rather than switching exhaustively on
it: the set grows. See Getting started.
Every signer produces a RECIPIENT_COMPLETED, including the last one. The signature that
finishes an envelope therefore produces two deliveries, queued in that order:
RECIPIENT_COMPLETED, then ENVELOPE_COMPLETED. That means you can count signatures by handling
RECIPIENT_COMPLETED uniformly, without a special case for the final signer, and use
ENVELOPE_COMPLETED purely for the transition.
They are queued in that order, not guaranteed to arrive in it. Each delivery retries on its own, so
a first attempt that fails can land after a later event succeeded. Order by the timestamp in the
payload, never by the order they reach you.
Delivery, retries and failures
We make eight attempts over about a day. Any response outside the 2xx range counts as a failure, as does a timeout; we wait up to 30 seconds for a response. After a failed attempt we wait before the next one, for longer each time:
| After attempt | We wait | So the next attempt runs about this long after the first |
|---|---|---|
| 1 | 1 minute | 1 minute |
| 2 | 5 minutes | 6 minutes |
| 3 | 30 minutes | 36 minutes |
| 4 | 2 hours | 2 hours 36 minutes |
| 5 | 5 hours | 7 hours 36 minutes |
| 6 | 8 hours | 15 hours 36 minutes |
| 7 | 8 hours | 23 hours 36 minutes |
If the eighth attempt fails, the delivery becomes a dead letter: we stop retrying it and keep it, so you can send it again once your endpoint is working. We also email your account's owners, naming the endpoint by its host, the event and why the last attempt failed. That email is sent at most once a day per endpoint and counts everything else waiting on it, so an endpoint that is down for a day produces one email, not one per event.
Redirects are not followed. A 3xx is a failed delivery, so register the final URL rather than one that forwards. The destination is checked before every attempt, and the request goes only to the address that check approved. If the host name does not resolve at that moment, the attempt counts as a failure and is retried like any other.
Every attempt uses the endpoint as it is at that moment. A delivery that is waiting to be retried goes to the endpoint's current URL, signed with its current secret, so a secret you regenerate takes effect for deliveries already in progress. Disabling an endpoint stops deliveries to it, including those already waiting: when a waiting delivery's next attempt comes round, it is not sent and is marked as a dead letter, with a note that the endpoint was disabled. You can retry it once the endpoint is enabled again.
Every attempt is recorded. GET /v1/webhooks/{id}/deliveries lists them with their status and the
response status code we received, GET /v1/webhooks/dead-letter lists deliveries that exhausted their
attempts, and POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry sends one again. A retry
makes up to three attempts: at once, a minute later, and five minutes after that. If the delivery
is still waiting between automatic attempts, a retry brings its next attempt forward to run now
instead, and the rest of its schedule carries on as before; the response then carries
"broughtForward": true. A retry is refused with 409 Conflict while an attempt of that delivery
is already running or about to run; wait for it to finish. There is also
POST /v1/webhooks/{id}/test, which posts a sample delivery so you can confirm a new endpoint is
reachable and verifying correctly before you rely on it. A test delivery carries every header a
real one does, with an eventId of its own, and is not recorded in the delivery list. The
delivery list and the dead-letter list give each delivery's eventId.
Building a receiver that behaves
Answer 2xx immediately, then do the work. Verify the signature, put the payload on your own queue, and return. Doing the work inside the request risks a timeout, and a timeout is a retry.
Deduplicate on eventId. A retry after your handler succeeded but its response was lost is
the normal case, not the exotic one. Record the ids you have processed, and answer 2xx to one you
have already seen without doing the work again.
Tolerate new fields. We add fields to payloads; a receiver that rejects an unrecognised key will break on a change that breaks nobody else.
Handle a short payload. Rarely, an event's data carries only the id of what it is about and
"payloadIncomplete": true, when its full payload could not be assembled at the moment the event
was recorded. The event itself is real and is delivered as usual, with its eventId. Read the
resource by that id to get the rest, rather than treating the delivery as malformed.
Do not rely on arrival order. Deliveries are queued independently, so use the timestamps in the payload to establish sequence.
Handle your own downstream errors internally. Return 2xx as soon as you have accepted the payload, so that a fault in your database does not consume your retry budget.