Browse developers
Start here
Working with the API
Receiving events
Proposals
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", "PROPOSAL_ACCEPTED"]
}'
{
"id": "c4a1e0d2-77b3-4f61-9a0c-2e5d8b1f3a44",
"url": "https://api.example.com/webhooks/cecursign",
"events": ["ENVELOPE_COMPLETED", "ENVELOPE_DECLINED", "PROPOSAL_ACCEPTED"],
"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 and proposal 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-Delivery: 6d2f9c11-3a4b-4c5d-8e9f-0a1b2c3d4e5f
X-Cecur-Signature: t=1787654321,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
User-Agent: CecurSign-Webhook/1.0
{
"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 three-field envelope: the event name, an ISO 8601 timestamp, and a data
object whose shape depends on the event. X-Cecur-Delivery is stable across the retries of one
delivery, which makes it the right key to deduplicate on.
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
Envelopes
| 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.
These fire for the envelope behind an accepted proposal too, because that acceptance is a real
signing ceremony. Two consequences worth designing for. One acceptance produces both
ENVELOPE_COMPLETED and PROPOSAL_ACCEPTED, so key your own side on the proposal if you act on
both. And withdrawing a proposal whose client had already begun signing voids that ceremony, so
ENVELOPE_VOIDED arrives for an envelope your integration may never have seen created.
A document the sender signed alone raises none of these. Nothing was sent to anybody, so a
SELF_SIGN envelope completes without a webhook.
Envelope responses carry source, so an envelope id from any of these events resolves to a
DIRECT, PROPOSAL_ACCEPTANCE or SELF_SIGN origin with one GET. 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.
Proposals
Every proposal event carries the same six identifying fields, then its own:
{
"proposalId": "8a2b6f10-9d3e-4c72-b1a5-0e7f2c4d6b83",
"proposalRef": "PRO-2026-0184",
"contactId": "1f4c9a20-5b6d-4e7f-8a90-b1c2d3e4f506",
"externalSource": null,
"externalClientRef": null,
"externalRef": null
}
externalSource names the system a proposal originated in, externalClientRef is that system's
identifier for the client and externalRef its identifier for the engagement. All three are null
for a proposal created in CecurSign.
| Event | Fires when | Additional data |
|---|---|---|
PROPOSAL_SENT | The proposal is issued to the client | sentAt, validUntil |
PROPOSAL_VIEWED | The client opens it for the first time | viewedAt |
PROPOSAL_ACCEPTED | The client accepts and signs | acceptedByName, acceptedByEmail, acceptedByAuthority, acceptedAt, currency, feeGroups, oneOffAmount, envelopeId, signedDocumentUrl |
PROPOSAL_DECLINED | The client declines | declinedAt, declineReason |
PROPOSAL_EXPIRED | It passes its validity date unanswered | expiredAt, validUntil |
PROPOSAL_TERM_ENDING | A fixed-term engagement nears its end date | engagementEndsAt, engagementStartsAt, daysRemaining, termDescription |
feeGroups on PROPOSAL_ACCEPTED is one row per billing cadence, and the rows are not additive:
"feeGroups": [
{ "frequency": "ONE_OFF", "periodsPerYear": 1, "subtotal": "600.00", "vatAmount": "138.00", "totalAmount": "738.00" },
{ "frequency": "MONTHLY", "periodsPerYear": 12, "subtotal": "150.00", "vatAmount": "34.50", "totalAmount": "184.50" }
]
Read Proposals before you put any of those figures into another system.
periodsPerYear is supplied so that an unfamiliar cadence can still be handled numerically, and
the set of cadences grows, so do not switch exhaustively on frequency.
PROPOSAL_TERM_ENDING is the one event with no client-side cause. It comes from a nightly sweep
because a date approached, it fires once per proposal rather than once per threshold crossed, and
it is not a state change: the proposal is accepted before and after. Place it on a timeline by
engagementEndsAt rather than by when it arrived.
Delivery, retries and failures
We make five attempts in total, backing off exponentially from a minute. Any response outside the 2xx range counts as a failure, as does a timeout; we wait up to 30 seconds for a response.
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.
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. 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.
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 X-Cecur-Delivery. A retry after your handler succeeded but its response was
lost is the normal case, not the exotic one.
Tolerate new fields. We add fields to payloads; a receiver that rejects an unrecognised key will break on a change that breaks nobody else.
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.