Webhooks — receive signature events in real time
Configure an HTTPS URL in your Certyneo dashboard and receive an HMAC-SHA256-signed POST every time an event happens on your envelopes: signed, declined, expired. 8 events supported, exponential retry over 6 attempts, cryptographic verification in 8 lines of code.
< 5s
Median delivery delay after the event
5x
Attempts on failure (up to ~9h later)
HMAC-SHA256
Signing algorithm for every request
Event catalog
The 8 events below cover the full life cycle of a Certyneo envelope. Enable the ones you care about in Settings → Webhooks, ignore the rest — subscription is 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. The eIDAS-sealed PDF and audit trail are available via `proof_pdf_url` in the payload. |
envelope.declined | A signer declined the envelope. The decline reason (if provided) is in `data.decline_reason`. |
envelope.voided | The envelope was cancelled by the sender before completion. Distinct from `expired` (human vs timeout). |
envelope.expired | The envelope's expiration date passed without full signature. Missing signers are in `data.missing_signers`. |
recipient.signed | An individual signer signed (but not necessarily all). Useful for sequential workflows: trigger the next signer. |
recipient.viewed | A signer opened the signing link without signing yet. Useful for targeted sales follow-ups. |
Payload format
Every event shares the same top-level JSON schema: `event`, `envelope_id`, `occurred_at`, `data`. The contents of `data` vary by event (per-event fields documented in the API reference). Here is a complete `envelope.completed` example.
{
"event": "envelope.completed",
"envelope_id": "env_01HG3K8X9Y2N4P5Q6R7S8T9V0W",
"occurred_at": "2026-05-27T08:42:13.521Z",
"data": {
"title": "Contrat de prestation Acme Corp",
"status": "completed",
"created_at": "2026-05-24T14:12:00.000Z",
"completed_at": "2026-05-27T08:42:13.000Z",
"signers": [
{
"email": "client@acme.example",
"name": "Alex Client",
"signed_at": "2026-05-27T08:42:13.000Z",
"method": "eidas_aes",
"ip": "203.0.113.42"
}
],
"proof_pdf_url": "https://certyneo.com/api/envelopes/env_01HG.../proof.pdf"
}
}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 (shown once at creation in the dashboard, then encrypted at rest). The signature is transmitted in the `Certyneo-Signature` header as `t=<timestamp>,v1=<hex_hmac>`. ALWAYS verify the signature before processing the payload — without this 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 {
// Header format: t=<timestamp>,v1=<hex_hmac>
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=")),
);
const ts = parts.t;
const sig = parts.v1;
if (!ts || !sig) return false;
// Reject events older than 5 minutes (replay protection).
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
// timingSafeEqual to mitigate timing attacks.
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(sig, "hex"),
);
}Python
import hashlib
import hmac
import time
def verify_certyneo_signature(
raw_body: bytes, signature_header: str, secret: str
) -> bool:
"""Verify a Certyneo webhook signature.
Header format: `t=<timestamp>,v1=<hex_hmac>`
Rejects events older than 5 minutes (replay protection).
"""
parts = dict(p.split("=") for p in signature_header.split(","))
ts = parts.get("t")
sig = parts.get("v1")
if not ts or not sig:
return False
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{ts}.{raw_body.decode('utf-8')}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig)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 responds anything other than HTTP 2xx (timeout, 5xx, connection refused), we retry on exponential backoff. Total 6 attempts over ~9 hours. After 6, the event is marked `failed` in your webhook dashboard and an alert email is sent.
| Attempt | Cumulative delay | Elapsed time |
|---|---|---|
| #1 | 0 | 0 |
| #2 | + 1 min | 1 min |
| #3 | + 5 min | 6 min |
| #4 | + 30 min | 36 min |
| #5 | + 2 h | 2h 36 |
| #6 | + 6 h | 8h 36 |
If you want to manually replay a failed event, the webhook dashboard offers a 'Re-deliver' button on each failed delivery — available for 7 days after the final failure.
Test without sending a real envelope
The `/v1/webhooks/test` endpoint accepts a URL and an event type, and POSTs a fake payload signed exactly like a real one. Perfect for validating your handler in CI or during local development (with ngrok or similar).
# Trigger a test webhook to your endpoint
curl -X POST https://api.certyneo.com/v1/webhooks/test \
-H "Authorization: Bearer $CERTYNEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event": "envelope.completed", "url": "https://your.app/webhooks/certyneo"}'6 practices to follow
- Verify the HMAC signature BEFORE any body read — use a timing-safe comparison.
- Process each `envelope_id` × `event` only once by storing seen IDs in a database (retries can re-deliver the same event).
- Respond HTTP 200 within 5 seconds max, then process asynchronously (queue). Otherwise you'll time out and be counted as a retry.
- Log the raw body + full signature in debug — HMAC verification often fails on an invisible BOM or whitespace.
- Wire a dead-letter queue on `failed` events to manually replay on your-service incidents.
- Tolerate a 5-minute skew between `t=` and reception time — beyond that, reject to block replays.
Go further
Ready to connect your systems?
Create a free account in 2 minutes — webhook configuration is available from the Starter plan (free, 5 envelopes/month).