Webhook Signature Verification
このコンテンツはまだ日本語訳がありません。
Webhook Signature Verification
Section titled “Webhook Signature Verification”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.
Headers
Section titled “Headers”| Header | Contents |
|---|---|
Clienta-Event-Id | The unique ID of this event (stable across delivery retries of the same event) |
Clienta-Event-Type | The event type, e.g. conversation.created |
Clienta-Timestamp | Unix timestamp (seconds) the delivery was signed at |
Clienta-Signature | t={timestamp},v1={hex signature} — see below |
Signature format
Section titled “Signature format”Clienta-Signature: t=1784178600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt— the same Unix timestamp as theClienta-Timestampheader, 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 exacttit 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.
What’s signed
Section titled “What’s signed”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.
Verifying a signature
Section titled “Verifying a signature”// 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();});Replay tolerance
Section titled “Replay tolerance”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.
Secret rotation
Section titled “Secret rotation”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.