Delivery, signatures and receivers
Verifying a signature, handling retries and duplicates, ordering guarantees, and complete receiver implementations.
Last revised: 2026-08-23
Verifying the signature
Every delivery carries X-Cliento-Signature: sha256=<hex> - an HMAC-SHA256 over the raw request body, keyed with the subscription's secret. Verify it before you parse, and reject anything that fails.
- Read the raw body as bytes.
- Compute
HMAC-SHA256(secret, body)and hex-encode it. - Compare with the part of the header after
sha256=, using a constant-time comparison. - On mismatch, return 401 and process nothing.
Rotate the secret with POST /webhooks/{subscription_id}/rotate-secret when it may have leaked. The new secret takes effect immediately, so deploy the new value to your receiver first if you cannot tolerate a few rejected deliveries - they will be retried.
Retries and timeouts
Deliveries are queued, never sent inline with the request that caused them - so a slow endpoint of yours can never slow down the workspace's own CRM. A worker picks up due deliveries, POSTs them, and reschedules on failure.
| Behaviour | Value |
|---|---|
| Socket timeout | 10 seconds |
| Backoff after attempt 1, 2, 3, 4, 5 | 30 s, 2 min, 10 min, 30 min, 1 hour |
| Attempts before giving up | max_attempts on the subscription (default 5) |
| Counted as success | Any 2xx |
| Counted as failure | Any other status, a redirect, a timeout, a connection error |
After the last attempt the delivery is marked exhausted and is not retried again. GET /webhooks/{subscription_id}/deliveries?status=exhausted is where to look for what you missed; the row keeps the response status and body your endpoint returned, which is usually enough to see why.
curl -sS "$CLIENTO_HOST/api/v1/webhooks/7/deliveries?status=exhausted" \
-H "Authorization: Bearer $CLIENTO_TOKEN"Delivery semantics
Three properties to design against. None of them is a defect; all three are consequences of never losing a delivery.
- At-least-once, not exactly-once. An endpoint that processes a payload and then times out on the way back will see that delivery again. So will one whose worker died mid-POST, once the lease expires.
- No ordering guarantee. Deliveries are retried independently, so a
ContactUpdatedcan arrive before theContactCreatedit followed. Order bypayload.occurred_atif sequence matters to you, and treat an update for a record you have not seen as an instruction to fetch it. - The payload is a snapshot of an event, not of a record. By the time you process it the record may have changed again. Fetch the record over the API when you need its current state.
Handling duplicates
delivery_id is the deduplication key, and it is stable across retries of the same delivery. Record it before you act and skip anything you have already seen.
# 1. verify the signature
# 2. INSERT delivery_id into a table with a UNIQUE constraint
# -> conflict means "already handled", so return 200 and stop
# 3. enqueue the work
# 4. return 200
# Doing (2) before (3) is what makes the whole thing idempotent: the
# uniqueness constraint is the lock, so two concurrent deliveries of the
# same id produce exactly one unit of work.If you cannot store delivery ids, make the *effect* idempotent instead - an upsert keyed on payload.entity_type + entity_id produces the same result whether it runs once or five times.
Receiver: Node / Express
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.CLIENTO_WEBHOOK_SECRET!;
// The raw body is required for signature verification, so JSON parsing is
// deliberately NOT applied to this route - `express.json()` would hand us a
// re-serialized object whose bytes no longer match what was signed.
app.post(
"/cliento",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.header("X-Cliento-Signature") ?? "";
if (!verify(req.body, signature)) {
// Nothing is parsed and nothing is processed. 401 also stops Cliento
// from treating this as delivered.
return res.status(401).send("bad signature");
}
const envelope = JSON.parse(req.body.toString("utf8")) as {
delivery_id: number;
event_type: string;
timestamp: string;
payload: {
entity_type: string;
entity_id: number;
occurred_at: string;
metadata: Record<string, unknown>;
};
};
// Deduplicate BEFORE doing the work. `claim` inserts the id behind a
// unique constraint and returns false if it was already there.
if (!(await claim(envelope.delivery_id))) {
return res.status(200).send("duplicate");
}
// Answer first, work afterwards. The socket timeout is 10 seconds, and
// anything slower is recorded as a failed attempt even if it succeeds.
res.status(200).send("ok");
await enqueue(envelope);
},
);
function verify(body: Buffer, header: string): boolean {
const presented = header.startsWith("sha256=") ? header.slice(7) : header;
const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
// Constant-time: a plain === leaks how much of the signature was correct.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(presented, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
declare function claim(deliveryId: number): Promise<boolean>;
declare function enqueue(envelope: unknown): Promise<void>;
app.listen(3000);Receiver: Python / Flask
import hashlib
import hmac
import json
import os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["CLIENTO_WEBHOOK_SECRET"].encode()
def verify(body: bytes, header: str | None) -> bool:
"""Constant-time HMAC check over the RAW body."""
if not header:
return False
presented = header.split("=", 1)[1] if "=" in header else header
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
# compare_digest, never ==: a plain comparison leaks how much matched.
return hmac.compare_digest(expected, presented)
@app.post("/cliento")
def receive():
# `get_data()` gives the raw bytes. Do not use `request.json` before
# verifying - re-serializing changes the bytes and breaks the signature.
body = request.get_data()
if not verify(body, request.headers.get("X-Cliento-Signature")):
return {"error": "bad signature"}, 401
envelope = json.loads(body)
delivery_id = envelope["delivery_id"]
# Deduplicate before doing anything. `claim` inserts the id behind a
# UNIQUE constraint and returns False when it was already there, so two
# concurrent copies of one delivery produce exactly one unit of work.
if not claim(delivery_id):
return {"status": "duplicate"}, 200
# Queue the work rather than doing it here: the socket timeout is 10
# seconds, and a slower reply is recorded as a failed attempt even if the
# processing eventually succeeds - which is how a slow consumer turns into
# a stream of duplicate deliveries.
enqueue(envelope["event_type"], envelope["payload"])
return {"status": "ok"}, 200Testing your receiver
- Point a subscription at a tunnel to your development machine (
ngrok,cloudflared) and do something in the app that raises the event. - Read
GET /webhooks/{subscription_id}/deliveries- it records the status code and response body your endpoint returned, which is faster than reading your own logs. - Test the failure paths deliberately: return 500 once and watch the retry arrive after ~30 seconds; return 200 slowly (11 s) and confirm it is recorded as a failure.
- Test the duplicate path by replaying the same body and signature twice - your endpoint must answer 200 both times and do the work once.
- Test a bad signature: change one character and confirm you return 401 and process nothing.
BODY='{"delivery_id":1,"event_type":"ContactCreated","timestamp":"2026-08-23T10:21:07Z","payload":{"entity_type":"contact","entity_id":4182}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$CLIENTO_WEBHOOK_SECRET" -hex | sed 's/^.* //')
curl -sS -i -X POST http://localhost:3000/cliento \
-H "Content-Type: application/json" \
-H "X-Cliento-Signature: sha256=$SIG" \
-H "X-Cliento-Delivery-Id: 1" \
-H "X-Cliento-Event-Type: ContactCreated" \
--data "$BODY"Security recommendations
- Always verify the signature. An endpoint that skips it will process anything anyone posts to it.
- Compare in constant time. A byte-by-byte comparison leaks how much of a guessed signature was right.
- Keep the secret out of the repository and rotate it on any suspicion.
- Serve the endpoint over HTTPS with a valid certificate. Delivery to an invalid certificate fails.
- Do not trust the payload as authorization. It says a record changed; it does not grant access to that record. Read it back through the API with your own token.
- Bound what you accept. Reject bodies larger than you expect, and give your handler a timeout of its own.
- Use `masked_fields` so data that must not leave never enters the delivery in the first place - masking at the destination is a policy, masking at the source is a guarantee.