Saltearse al contenido

Webhook Signature Verification

Esta página aún no está disponible en tu idioma.

Every webhook delivery is signed with HMAC-SHA256, using the secret you received when you registered the endpoint. Always verify the signature before trusting a payload’s contents — an unverified webhook receiver will process forged requests from anyone who finds your URL.

HeaderContents
Clienta-Event-IdThe unique ID of this event (stable across delivery retries of the same event)
Clienta-Event-TypeThe event type, e.g. conversation.created
Clienta-TimestampUnix timestamp (seconds) the delivery was signed at
Clienta-Signaturet={timestamp},v1={hex signature} — see below
Clienta-Signature: t=1784178600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t — the same Unix timestamp as the Clienta-Timestamp header, included in the signed content itself (Stripe-style timestamp binding). This means a captured, valid signature can’t be replayed later under a different timestamp — only the exact t it was issued with will verify.
  • v1 — the hex-encoded HMAC-SHA256 signature.

During a secret rotation overlap window, the header carries multiple v1= entries — one per currently-valid secret — comma-separated:

Clienta-Signature: t=1784178600,v1=<sig-under-old-secret>,v1=<sig-under-new-secret>

Your verifier should accept the payload if any provided signature matches any secret you currently trust for that endpoint (see Secret rotation below) — this is what lets you roll your own stored secret without a delivery gap.

The signature is computed over:

{timestamp}.{raw request body}

using the endpoint secret as the HMAC key. Sign the raw bytes of the request body — not a re-serialized version of the parsed JSON, which can differ in whitespace/key order and would produce a signature mismatch that has nothing to do with a real forgery.

// Node.js — reference verifier. Adapt secret storage/lookup to your stack.
const crypto = require('node:crypto');
const REPLAY_TOLERANCE_SECONDS = 5 * 60; // 5 minutes
/**
* @param {string} signatureHeader the raw `Clienta-Signature` header value
* @param {Buffer} rawBody the UNPARSED request body bytes
* @param {Buffer[]} secrets every secret you currently accept for this endpoint
* (normally one; two during your own rotation overlap)
* @returns {boolean}
*/
function verifyClientaWebhookSignature(signatureHeader, rawBody, secrets) {
const parts = signatureHeader.split(',').map((p) => p.trim());
let timestamp;
const providedSignatures = [];
for (const part of parts) {
const [key, value] = part.split('=', 2);
if (key === 't' && value) {
timestamp = Number(value);
} else if (key === 'v1' && value) {
providedSignatures.push(value);
}
}
if (timestamp === undefined || Number.isNaN(timestamp) || providedSignatures.length === 0) {
return false;
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (Math.abs(nowSeconds - timestamp) > REPLAY_TOLERANCE_SECONDS) {
return false; // too old (or clock-skewed) — reject, don't just log a warning
}
for (const secret of secrets) {
const signedContent = Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signedContent).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
for (const provided of providedSignatures) {
let providedBuf;
try {
providedBuf = Buffer.from(provided, 'hex');
} catch {
continue;
}
if (providedBuf.length === expectedBuf.length && crypto.timingSafeEqual(providedBuf, expectedBuf)) {
return true;
}
}
}
return false;
}
// Usage in an Express-style handler — read the RAW body, not the JSON-parsed one:
app.post('/hooks/clienta', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyClientaWebhookSignature(
req.header('Clienta-Signature'),
req.body, // Buffer, thanks to express.raw()
// The secret you received is itself base64url-encoded — decode it back to raw
// bytes for HMAC. Do NOT hash the utf8 bytes of the string; that is a different key.
[Buffer.from(process.env.CLIENTA_WEBHOOK_SECRET, 'base64url')]
);
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event ...
res.status(200).end();
});

Signatures are rejected if Clienta-Timestamp is more than 5 minutes away from your server’s clock (in either direction). Keep your server’s clock synced (NTP) — clock drift is the most common cause of false-negative signature verification.

Rotating a webhook secret is done by registering a new endpoint and retiring the old one — there’s no in-place secret swap on a single endpoint. During the overlap between registering the new endpoint and deleting the old one, both endpoints are independently signed and delivered to, so there’s no gap where an event is unsigned or dropped.

Internally, Clienta.ai’s own envelope-encryption keys (used to encrypt signing secrets at rest) rotate on a separate schedule from your endpoint secrets and require no action from you — they don’t change your Clienta-Signature verification in any way.