Webhooks
Webhooks push the events of your organization to your HTTPS endpoint as they happen: a document is signed, a registered mail is delivered, a task is created… Use them instead of polling.
Set up a subscription
Section titled “Set up a subscription”- Organizations: an administrator (
integrations.manage) creates subscriptions in Settings → Integrations → Webhooks: an HTTPS URL, a description and the events to receive. The signing secret (whsec_…) is shown once. - Partner applications: subscriptions are attached to the application by MyCLM. They receive the events of every organization that granted the application access.
A subscription can be disabled and enabled again, its secret rotated (the new secret applies immediately to the next deliveries), and Send a test event delivers a webhook.test event on demand.
Delivery
Section titled “Delivery”Each event is POSTed as JSON to every subscription listening to its type:
POST /webhooks/mylegitech HTTP/1.1Content-Type: application/jsonUser-Agent: MyCLM-Webhooks/1X-MLT-Signature: t=1790849566,v1=5d2f8a0c7e1b4a9f3c6d8e2b7a1f0c9d4e8b3a6f2c1d7e9b0a4f8c3d6e2b1a7fX-MLT-Event-Id: 0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0eX-MLT-Event-Type: signature.completedX-MLT-Delivery-Id: 0192f3b0-1301-7a2c-8d4e-6f7a8b9c0d1eX-MLT-Livemode: true
{"id":"0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e","type":"signature.completed","apiVersion":"1","createdAt":"2026-10-01T10:12:45.000Z","providerId":"0192f3a1-7c2e-7b4d-9a61-100000000001","orgId":"0192f3a1-7c2e-7b4d-9a61-200000000001","livemode":true,"data":{"requestId":"0192f3a1-7c2e-7b4d-9a61-400000000001","title":"Bail - Lot 12"}}| Field | Description |
|---|---|
id |
Event id. Identical across retries and replays: deduplicate on it. |
type |
Event type, see the catalog below. |
apiVersion |
Payload version, currently "1". |
createdAt |
When the event occurred. |
providerId, orgId |
Provider and organization of the event. |
livemode |
true for a production event, false for an event of a test organization. |
data |
Identifiers and business status only. Read the details through the API, with your own rights. |
| Header | Description |
|---|---|
X-MLT-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256>, see Verify the signature. |
X-MLT-Event-Id |
Same as id. |
X-MLT-Event-Type |
Same as type. |
X-MLT-Delivery-Id |
One delivery per event and subscription. |
X-MLT-Livemode |
Same as livemode: true or false. |
Deliveries are at least once and not ordered: use createdAt, and re-read the resource when the order matters.
Verify the signature
Section titled “Verify the signature”The signature is an HMAC-SHA256 of <t>.<raw body> with the signing secret of the subscription (the whole whsec_… string). Verify it on the raw bytes of the body, before any JSON parsing, and reject timestamps older than 5 minutes to block replays.
import express from 'express';import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.MLT_WEBHOOK_SECRET!; // whsec_…const TOLERANCE_S = 5 * 60;
export function verifySignature(rawBody: Buffer, header: string | undefined, secret: string): boolean { if (!header) return false; const parts = new Map(header.split(',').map((part) => { const index = part.indexOf('='); return [part.slice(0, index).trim(), part.slice(index + 1)] as const; })); const t = Number(parts.get('t')); if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false; const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex')); const given = Buffer.from(parts.get('v1') ?? ''); return given.length === expected.length && timingSafeEqual(given, expected);}
const app = express();app.post('/webhooks/mylegitech', express.raw({ type: 'application/json' }), (req, res) => { if (!verifySignature(req.body, req.get('X-MLT-Signature'), SECRET)) return res.sendStatus(400); const event = JSON.parse(req.body.toString('utf8')); res.sendStatus(204); // acknowledge first, then process asynchronously queue.push(event); // your own job queue, deduplicated by event.id});import hashlib, hmac, os, timefrom flask import Flask, request, abort
SECRET = os.environ["MLT_WEBHOOK_SECRET"] # whsec_…TOLERANCE_S = 5 * 60
def verify_signature(raw_body: bytes, header: str | None, secret: str) -> bool: if not header: return False try: parts = dict(part.strip().split("=", 1) for part in header.split(",")) t = int(parts["t"]) except (KeyError, ValueError): return False if abs(time.time() - t) > TOLERANCE_S: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/webhooks/mylegitech")def mylegitech_webhook(): if not verify_signature(request.get_data(), request.headers.get("X-MLT-Signature"), SECRET): abort(400) event = request.get_json() enqueue(event) # your own job queue, deduplicated by event["id"] return "", 204<?phpfunction verifySignature(string $rawBody, ?string $header, string $secret, int $toleranceS = 300): bool{ if (!$header) { return false; } $parts = []; foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); $parts[$key] = $value; } $t = filter_var($parts['t'] ?? null, FILTER_VALIDATE_INT); if ($t === false || abs(time() - $t) > $toleranceS) { return false; } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1'] ?? '');}
$rawBody = file_get_contents('php://input');if (!verifySignature($rawBody, $_SERVER['HTTP_X_MLT_SIGNATURE'] ?? null, getenv('MLT_WEBHOOK_SECRET'))) { http_response_code(400); exit;}$event = json_decode($rawBody, true);http_response_code(204); // then process $event asynchronously, deduplicated by $event['id']# Recompute the signature of a captured delivery: body.json holds the raw body, T the t= value of the header.T=1790849566{ printf '%s.' "$T"; cat body.json; } | openssl dgst -sha256 -hmac "$MLT_WEBHOOK_SECRET" -hex# Compare the output with the v1= value of X-MLT-Signature.Respond quickly
Section titled “Respond quickly”Answer any 2xx status within 10 seconds, then process the event in the background. Anything else is a failure: another status, a timeout, a network error, or a redirect (redirects are not followed). The first 500 characters of your response are kept in the delivery log to help you debug.
Retries
Section titled “Retries”A failed delivery is retried with exponential backoff, 9 attempts over about 23 hours:
| Attempt | Delay after the previous attempt |
|---|---|
| 1 | Immediately |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 30 minutes |
| 6 | 1 hour |
| 7 | 3 hours |
| 8 | 6 hours |
| 9 | 12 hours |
After the last attempt the delivery is given up (dead). After 15 consecutive failed attempts, the subscription is disabled automatically and the administrators of the organization are notified by email; fix your endpoint, enable the subscription again, then replay the missed deliveries.
Replay
Section titled “Replay”The delivery log of a subscription (Settings → Integrations → Webhooks) lists every delivery of the last 30 days with its status, attempts, response code and payload. Replay sends a delivery again immediately with a fresh retry budget, and the same event id.
Event catalog
Section titled “Event catalog”Generated from the API source. The schema of each payload is also in the API reference, under Webhooks.
| Event | When |
|---|---|
signature.signed | A signer signed |
signature.completed | Signature request completed |
signature.declined | A signer declined |
signature.expired | Signature request expired |
signature.canceled | Signature request canceled |
registered_mail.delivered | Registered mail delivered |
registered_mail.refused | Registered mail refused |
registered_mail.neglected | Registered mail neglected |
registered_mail.failed | Registered mail failed |
task.created | Task created |
task.completed | Task occurrence completed |
member.added | Member added |
contact.created | Contact created |
contact.updated | Contact updated |
webhook.test | Test event |
signature.signed
Sent each time one signer of a signature request signs. The request is completed once every signer has signed (signature.completed).
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "signature.signed", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "requestId": "0192f3a1-7c2e-7b4d-9a61-400000000001", "title": "Bail - Lot 12", "signerId": "0192f3a1-7c2e-7b4d-9a61-400000000011", "signerName": "Marie Durand" }}signature.completed
Every signer signed and the signed document is available (GET /v1/signature-requests/{id}/document?variant=signed).
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "signature.completed", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "requestId": "0192f3a1-7c2e-7b4d-9a61-400000000001", "title": "Bail - Lot 12" }}signature.declined
A signer declined to sign: the request is declined and nobody else can sign it.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "signature.declined", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "requestId": "0192f3a1-7c2e-7b4d-9a61-400000000001", "title": "Bail - Lot 12", "signerId": "0192f3a1-7c2e-7b4d-9a61-400000000012", "signerName": "Paul Martin", "reason": "Le montant du dépôt de garantie est erroné." }}signature.expired
The request reached its expiry date before every signer signed: it is expired and can no longer be signed.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "signature.expired", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "requestId": "0192f3a1-7c2e-7b4d-9a61-400000000001", "title": "Bail - Lot 12" }}signature.canceled
The request was canceled by its organization: it is canceled and can no longer be signed.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "signature.canceled", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "requestId": "0192f3a1-7c2e-7b4d-9a61-400000000001", "title": "Bail - Lot 12" }}registered_mail.delivered
The recipient accepted the registered mail. The delivery proof becomes downloadable.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "registered_mail.delivered", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "mailId": "0192f3a1-7c2e-7b4d-9a61-500000000001", "itemId": "0192f3a1-7c2e-7b4d-9a61-500000000011", "status": "delivered", "reference": "LRE-2026-0142", "entityId": "0192f3a1-7c2e-7b4d-9a61-200000000002" }}registered_mail.refused
The recipient explicitly refused the registered mail.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "registered_mail.refused", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "mailId": "0192f3a1-7c2e-7b4d-9a61-500000000001", "itemId": "0192f3a1-7c2e-7b4d-9a61-500000000011", "status": "refused", "reference": "LRE-2026-0142", "entityId": "0192f3a1-7c2e-7b4d-9a61-200000000002" }}registered_mail.neglected
The recipient did not accept the registered mail within the legal period.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "registered_mail.neglected", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "mailId": "0192f3a1-7c2e-7b4d-9a61-500000000001", "itemId": "0192f3a1-7c2e-7b4d-9a61-500000000011", "status": "neglected", "reference": "LRE-2026-0142", "entityId": "0192f3a1-7c2e-7b4d-9a61-200000000002" }}registered_mail.failed
The item could not be delivered: the email bounced (status: bounced) or the vendor refused the item (status: failed).
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "registered_mail.failed", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "mailId": "0192f3a1-7c2e-7b4d-9a61-500000000001", "itemId": "0192f3a1-7c2e-7b4d-9a61-500000000011", "status": "bounced", "reference": "LRE-2026-0142", "entityId": "0192f3a1-7c2e-7b4d-9a61-200000000002" }}task.created
A task was created (by a person, an integration or automatically).
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "task.created", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "taskId": "0192f3a1-7c2e-7b4d-9a61-700000000001", "title": "Relancer Marie Durand - état des lieux", "entityId": "0192f3a1-7c2e-7b4d-9a61-200000000002", "assigneeId": "0192f3a1-7c2e-7b4d-9a61-300000000003", "startAt": "2026-10-06T07:00:00.000Z" }}task.completed
An occurrence of a task was marked as done. Recurring tasks send one event per completed occurrence.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "task.completed", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "taskId": "0192f3a1-7c2e-7b4d-9a61-700000000001", "title": "Relancer Marie Durand - état des lieux", "entityId": "0192f3a1-7c2e-7b4d-9a61-200000000002", "occurrenceAt": "2026-10-06T07:00:00.000Z", "completedBy": "0192f3a1-7c2e-7b4d-9a61-300000000003" }}member.added
A user was attached to a node of the organization (invitation, provisioning). newAccount is true when the user account was created by this operation.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "member.added", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "userId": "0192f3a1-7c2e-7b4d-9a61-300000000003", "nodeId": "0192f3a1-7c2e-7b4d-9a61-200000000004", "newAccount": true }}contact.created
A shared contact was created (manually, by import or through the API). Private contacts never trigger webhooks.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "contact.created", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "contactId": "0192f3a1-7c2e-7b4d-9a61-600000000001", "kind": "person", "entityId": "0192f3a1-7c2e-7b4d-9a61-200000000002" }}contact.updated
A shared contact was updated. Private contacts never trigger webhooks.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "contact.updated", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "contactId": "0192f3a1-7c2e-7b4d-9a61-600000000001", "kind": "person", "entityId": "0192f3a1-7c2e-7b4d-9a61-200000000002" }}webhook.test
Sent by the Send a test event action of a subscription (Settings > Integrations > Webhooks). It cannot be subscribed to.
{ "id": "0192f3b0-12c4-7e8a-b3d1-5f6a7b8c9d0e", "type": "webhook.test", "apiVersion": "1", "createdAt": "2026-10-01T10:12:45.000Z", "providerId": "0192f3a1-7c2e-7b4d-9a61-100000000001", "orgId": "0192f3a1-7c2e-7b4d-9a61-200000000001", "data": { "message": "Test event from MyCLM", "subscriptionId": "0192f3a1-7c2e-7b4d-9a61-900000000001" }}