Verifying webhooks
Each delivery includes three headers:
X-Webhook-Id: <uuid>X-Webhook-Timestamp: 1748254473X-Webhook-Signature: v1=<hex>The signature is built as:
HMAC_SHA256( key = shared_secret, msg = timestamp + "." + raw_body).hex_digest()It is then prefixed with v1= so future schemes can coexist. The shared
secret is the value you sent as key when creating the webhook;
Quiddly stores it encrypted at rest and never echoes it back.
Verification
Section titled “Verification”import hmacimport hashlibimport time
def verify(secret: str, body: bytes, timestamp: str, signature: str) -> bool: if abs(time.time() - int(timestamp)) > 5 * 60: return False mac = hmac.new( key=secret.encode("utf-8"), msg=timestamp.encode("utf-8") + b"." + body, digestmod=hashlib.sha256, ) expected = f"v1={mac.hexdigest()}" return hmac.compare_digest(expected, signature)import crypto from 'node:crypto';
export function verify(secret, body, timestamp, signature) { if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const mac = crypto .createHmac('sha256', secret) .update(`${timestamp}.`) .update(body) .digest('hex'); const expected = `v1=${mac}`; return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));}A 5-minute clock-skew tolerance is recommended.
Rotating the secret
Section titled “Rotating the secret”Update the webhook with a new key:
curl -X PUT "https://$HOST/webhook/$WH_ID" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{ "key": "<new-shared-secret>" }'Quiddly re-encrypts the new secret and signs subsequent deliveries with it. There is no overlap window today; coordinate the cutover with your receiver.
Header secret (alternative auth)
Section titled “Header secret (alternative auth)”If your receiver expects a static shared secret in a header rather than
HMAC, set useHeaderSecret: true and keyParam: "<header-name>" on the
subscription. Quiddly will then attach the secret as that header instead
of signing the body. HMAC remains the default and recommended scheme.