Back to documentation

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.

  1. Read the raw body as bytes.
  2. Compute HMAC-SHA256(secret, body) and hex-encode it.
  3. Compare with the part of the header after sha256=, using a constant-time comparison.
  4. 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.

BehaviourValue
Socket timeout10 seconds
Backoff after attempt 1, 2, 3, 4, 530 s, 2 min, 10 min, 30 min, 1 hour
Attempts before giving upmax_attempts on the subscription (default 5)
Counted as successAny 2xx
Counted as failureAny 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.

bash·What failed, and 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 ContactUpdated can arrive before the ContactCreated it followed. Order by payload.occurred_at if 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.

python·The pattern, in words
# 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

typescript·server.ts
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

python·receiver.py
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"}, 200

Testing your receiver

  1. Point a subscription at a tunnel to your development machine (ngrok, cloudflared) and do something in the app that raises the event.
  2. 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.
  3. 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.
  4. Test the duplicate path by replaying the same body and signature twice - your endpoint must answer 200 both times and do the work once.
  5. Test a bad signature: change one character and confirm you return 401 and process nothing.
bash·Replay a delivery against your local receiver
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.

Need technical help?

If your question is not answered here, get in touch with our team.

Contact us
Delivery, signatures and receivers · Cliento Pro