Webhooks — receive signature events in real time
Configure an HTTPS URL in your Certyneo dashboard and receive an HMAC-SHA256 signed POST as soon as an event happens on your envelopes: signature, decline, expiry. 11 supported events, 5 delivery attempts with exponential backoff, cryptographic verification in 8 lines of code.
< 5s
Median delivery delay after the event
5x
Delivery attempts in total, spread over roughly 1 h 20
HMAC-SHA256
Signing algorithm for every request
Event catalog
The 12 events below cover the entire lifecycle of a Certyneo envelope. Enable the ones you care about under Settings → Webhooks and ignore the rest — subscriptions are granular per event.
| Event | Triggered when |
|---|---|
envelope.created | An envelope is created (via UI, API or template) — useful to sync a CRM record at creation time. |
envelope.sent | The envelope is sent to signers (first email out). Marks the start of the active signing cycle. |
envelope.completed | Every signer has signed and the sealed eIDAS PDF is stored. The payload carries signedDocumentUrl, a presigned link valid for 7 days; otherwise GET /v1/envelopes/id/signed-document, and the audit trail via GET /v1/envelopes/id/audit-trail. |
envelope.declined | A signer declined the envelope. The address of whoever declined is in `data.declinedBy`, and the reason, when one was given, in `data.reason`. |
envelope.voided | The envelope was cancelled by the sender before completion. Distinct from `expired` (human vs timeout). |
envelope.expired | The envelope's expiry date passed without a complete signature. Query GET /v1/envelopes/id to find out which signers are missing. |
envelope.returned_to_sender | A signer sent the envelope back to the issuer for correction without declining it. The reason is in `data.reason` and the author of the return in `data.returnedBy`. |
envelope.resubmitted | The issuer corrected and re-sent an envelope that had previously been returned. Marks the resumption of the signing cycle. |
recipient.signed | One individual signer has signed (not necessarily all of them). Useful to track progress and to chain the next step of a sequential workflow. Careful: the sealed PDF does not exist yet at this point, not even for the last signer — a download triggered here returns an HTTP 409. Use envelope.completed for the document. |
recipient.viewed | A signer opened the signing link without signing yet. Useful for targeted sales follow-ups. |
recipient.approved | An approver validated the envelope without applying a signature to it (internal approval workflow). The address is in `data.approvedBy`. |
recipient.bounced | A recipient's mail server permanently rejected the invitation or a reminder (mailbox does not exist, dead domain). The recipient moves to the BOUNCED status and automatic reminders stop. The address is in `data.recipientEmail`: fix it, then send the envelope again. |
recipient.signed does not mean the document is ready
recipient.signed fires for EVERY signer, the moment that person completes their signature — including the last one, before the sealed PDF is assembled and stored. A download triggered from that handler therefore always gets an HTTP 409 "Signed document not available until the envelope is COMPLETED". That is not an error: it means "not ready yet". Subscribe to envelope.completed to fetch the document, and keep recipient.signed for progress tracking (who signed, and when).
Payload format
Every delivery shares the same top-level JSON schema: `event`, `data` and `timestamp`. The event name is repeated in the `X-Certyneo-Event` header too, so you can route before parsing the body at all. The contents of `data` vary per event, but always stay a flat object of simple values — never an array, never a nested object. Here is a complete `envelope.completed` delivery.
POST /webhooks/certyneo HTTP/1.1
Host: your.app
Content-Type: application/json
X-Certyneo-Event: envelope.completed
X-Certyneo-Delivery-Id: cm8f2h6a10004qr9k5p2wm3xt
X-Certyneo-Signature: 4f3d1c8b2a9e7f60d5c4b3a2918e7f6d5c4b3a2918e7f6d5c4b3a2918e7f6d5c{
"id": "cm8f2h6a10004qr9k5p2wm3xt",
"event": "envelope.completed",
"data": {
"envelopeId": "cm7x2k9p40001qz8h3f7bn2ld",
"sandbox": false,
"subject": "Contrat de prestation Acme Corp",
"status": "COMPLETED",
"signatureLevel": "ADVANCED",
"aesSignerCount": 2,
"completedAt": "2026-05-27T08:42:13.000Z",
"recipientCount": 2,
"recipients": [
{ "email": "alice@acme.com", "name": "Alice Martin", "role": "SIGNER", "status": "SIGNED" },
{ "email": "bob@acme.com", "name": "Bob Durand", "role": "SIGNER", "status": "SIGNED" }
],
"signedDocumentUrl": "https://storage.certyneo.com/signed/cm7x2k9p4.../contrat.pdf?X-Amz-Expires=604800&X-Amz-Signature=..."
},
"timestamp": "2026-05-27T08:42:13.521Z"
}signedDocumentUrl is a presigned link valid for 7 days from the event: it saves you a second authenticated call to fetch the PDF. It is absent — not null — when presigning failed, or on a QES envelope completed without a stored document; fall back to GET /v1/envelopes/id/signed-document, which remains the source of truth. Mind manual dead-letter-queue replays more than 7 days after the event too: the link in the payload has expired, the API endpoint has not.
The payload is UTF-8 encoded, no BOM. The HMAC signature is computed over the raw body as transmitted — don't reformat whitespace; re-parsing JSON often reorders keys and breaks verification.
Verify the HMAC signature
Every request is signed with your webhook secret (displayed only once, when the subscription is created). The signature travels in the `X-Certyneo-Signature` header: it is the HMAC-SHA256 of the raw request body, hex-encoded, with no prefix and no timestamp. ALWAYS verify the signature before processing the payload — without that step, anyone can forge an event and call your endpoint.
Node.js / TypeScript
import crypto from "node:crypto";
export function verifyCertyneoSignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
// X-Certyneo-Signature = HMAC-SHA256(secret, rawBody), hex-encoded.
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const received = Buffer.from(signatureHeader, "hex");
const computed = Buffer.from(expected, "hex");
// timingSafeEqual throws when the two buffers differ in length —
// a malformed header must return false, not crash the handler.
if (received.length !== computed.length) return false;
// timingSafeEqual to mitigate timing attacks.
return crypto.timingSafeEqual(computed, received);
}Python
import hashlib
import hmac
def verify_certyneo_signature(
raw_body: bytes, signature_header: str, secret: str
) -> bool:
"""Verify a Certyneo webhook signature.
`X-Certyneo-Signature` holds the hex-encoded HMAC-SHA256 of the
raw request body, computed with your webhook secret.
"""
expected = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)Common mistake
Do NOT use `===` or `==` to compare the expected signature against the received one. Use a timing-safe function (`crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python). Otherwise, the comparison-time delta between two signatures progressively reveals the secret to a patient attacker (timing attack).
Retry policy
If your endpoint takes too long to answer, refuses the connection or returns a 5xx (or a 429), we retry with exponential backoff: 5 attempts in all, over roughly 1 h 20. After the fifth the event goes to the dead-letter queue — still listed and replayable from your dashboard, but no longer retried automatically. An outright refusal (401, 403, 404, 410, 422…) is never retried: the answer would not change, so the event goes straight to the dead-letter queue.
| Attempt | Delay before the attempt | Time elapsed since the event |
|---|---|---|
| #1 | 0 | 0 |
| #2 | + 1 min | 1 min |
| #3 | + 5 min | 6 min |
| #4 | + 15 min | 21 min |
| #5 | + 1 h | 1 h 21 |
The delays above are minimums: the retry sweep is driven by a cron, so an attempt can fire slightly after its theoretical time. Abandoned events are listed under Webhooks → Failures, with a manual replay button and no retention limit. An endpoint that racks up five final failures — or answers 404 / 410 — is disabled automatically and you are told by email: re-enable it once fixed and the counter starts from zero (any successful delivery resets it too).
Test without sending a real envelope
Create a subscription first — the response carries the `secret` you need for HMAC verification, shown only once. From Settings → Webhooks, the « Test » button then sends a signed POST exactly like a real delivery and shows you your server's raw response: it is the fastest way to validate your handler, locally through ngrok or in continuous integration.
# Create a subscription — "secret" is returned once, store it now.
curl -X POST https://api.certyneo.com/v1/webhooks \
-H "Authorization: Bearer $CERTYNEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your.app/webhooks/certyneo",
"events": ["envelope.completed", "recipient.signed"]
}'The URL must be publicly reachable over HTTPS: a private or localhost address is rejected by the SSRF guard when the subscription is created.
Subscriptions are split by environment, like the keys: a webhook created with an sk_test_ key receives only the events of test envelopes, a webhook created with an sk_live_ key only those of real envelopes. A test envelope therefore never reaches your production URL, and every payload states "sandbox" (true or false).
6 practices to follow
- Verify the HMAC signature BEFORE any body read — use a timing-safe comparison.
- Process each delivery exactly once by storing the `id` values you have already seen (also sent as the `X-Certyneo-Delivery-Id` header) — a retry resends the same `id` if your 2xx got lost on the way back; `data.envelopeId` × `event` is not enough, since two events of the same type on the same envelope share that pair.
- Answer HTTP 2xx within 10 seconds at most, then process asynchronously (queue). Past that, the delivery is cut off and counted as a failure.
- Log the raw body + full signature in debug — HMAC verification often fails on an invisible BOM or whitespace.
- Watch the Webhooks → Failures page: you are emailed if your endpoint gets disabled, but not event by event — replaying an event that spent its attempts is on you.
- Filter events at subscription time rather than inside your handler, and stay under the limit of 5 subscriptions per account.
You do not have to host an endpoint to receive these events: the connector creates the subscription for you and starts the flow straight from an envelope event. See Certyneo for Power Automate and Microsoft 365.
Learn more
Ready to connect your systems?
Webhooks and the REST API are included from the Standard plan onwards. Create your account, generate an `sk_test_` key and wire your endpoint up in the sandbox before going to production.