Skip to content
Browse developers

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

EventFires whendata
ENVELOPE_SENTAn envelope goes out to its recipientsenvelopeId, subject, sentAt, recipientCount, signerCount
ENVELOPE_VIEWEDA recipient opens the envelope for the first timeenvelopeId, recipientId, recipientEmail, recipientName, viewedAt
RECIPIENT_COMPLETEDA signer completes their partenvelopeId, recipientId, recipientEmail, recipientName, signedAt
ENVELOPE_COMPLETEDEvery signer has signedenvelopeId, subject, completedAt, totalSigners
ENVELOPE_DECLINEDA recipient declines to signenvelopeId, subject, declinedBy, declinedByName, reason, declinedAt
ENVELOPE_VOIDEDThe sender withdraws the envelopeenvelopeId, subject, voidedAt, voidReason
ENVELOPE_EXPIREDThe expiry date passes with signatures outstandingenvelopeId, 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.

EventFires whenAdditional data
PROPOSAL_SENTThe proposal is issued to the clientsentAt, validUntil
PROPOSAL_VIEWEDThe client opens it for the first timeviewedAt
PROPOSAL_ACCEPTEDThe client accepts and signsacceptedByName, acceptedByEmail, acceptedByAuthority, acceptedAt, currency, feeGroups, oneOffAmount, envelopeId, signedDocumentUrl
PROPOSAL_DECLINEDThe client declinesdeclinedAt, declineReason
PROPOSAL_EXPIREDIt passes its validity date unansweredexpiredAt, validUntil
PROPOSAL_TERM_ENDINGA fixed-term engagement nears its end dateengagementEndsAt, 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.