Verifying signatures
Check that a webhook really came from Cavuno before you act on it.
A
JAnyone can send a request to your webhook URL. Cavuno signs every delivery so you can tell real events from forged ones. Verify the signature before you trust the payload.
Cavuno follows the Standard Webhooks specification, so an existing library for that spec will work.
The headers
Every delivery carries three headers.
123webhook-id: evt_01EXAMPLEJOBUPDATEDwebhook-timestamp: 1784866320webhook-signature: v1,g0hM9SsE+OTHER+BASE64+SIGNATURE
| Header | What it is |
|---|---|
webhook-id | The event ID. Stable across retries and replays. |
webhook-timestamp | Unix seconds for this attempt. Changes on every retry. |
webhook-signature | One or more signatures, space separated, each prefixed v1,. |
What gets signed
The signature covers three parts joined by full stops:
1<webhook-id>.<webhook-timestamp>.<raw-body>
Cavuno takes your secret, drops the whsec_ prefix, and base64-decodes the rest to get the signing key. It then computes HMAC SHA-256 over that string, base64-encodes the digest, and prefixes it with v1,.
Sign the raw bytes of the request body exactly as they arrived. If you parse the JSON and re-serialise it before signing, the bytes will differ and the signature will not match.
Verify in Node
12345678910111213141516171819202122232425262728293031323334353637const crypto = require('node:crypto');function verifyCavunoWebhook(rawBody, headers, secret) {const id = headers['webhook-id'];const timestamp = Number(headers['webhook-timestamp']);const signatures = String(headers['webhook-signature'] || '').split(/\s+/).filter(Boolean);if (!id || !Number.isFinite(timestamp) || !secret.startsWith('whsec_')) {throw new Error('Missing webhook headers or secret');}if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) {throw new Error('Webhook timestamp is outside the tolerance window');}const key = Buffer.from(secret.slice('whsec_'.length), 'base64');const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody.toString('utf8')}`).digest('base64');const valid = signatures.some((entry) => {const [version, value] = entry.split(',', 2);if (version !== 'v1' || !value || value.length !== expected.length) {return false;}return crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));});if (!valid) {throw new Error('Invalid webhook signature');}return JSON.parse(rawBody.toString('utf8'));}
Compare signatures in constant time, as above. A plain === comparison leaks timing information that can help an attacker guess a valid signature.
In Express, ask for the raw body rather than parsed JSON:
123456789101112131415161718192021app.post('/cavuno-webhooks',express.raw({ type: 'application/json' }),(req, res) => {let event;try {event = verifyCavunoWebhook(req.body,req.headers,process.env.CAVUNO_WEBHOOK_SECRET,);} catch {res.sendStatus(400);return;}res.sendStatus(202);processLater(event);},);
Check the timestamp
Reject any delivery whose timestamp is more than five minutes away from your own clock. This stops someone replaying a captured request later.
The timestamp describes the delivery attempt, not the event. It changes when Cavuno retries or when you replay a delivery, while webhook-id and the body stay the same. So use the timestamp for freshness and webhook-id for deduplication, never the other way round.
Rotate a secret
Use Rotate secret on an endpoint if a secret is exposed or lost. The new secret is shown once, and the old one keeps working for 24 hours.
During that window Cavuno signs each delivery with both secrets and puts both signatures in the header, separated by a space:
1webhook-signature: v1,SIGNATURE_FROM_OLD_SECRET v1,SIGNATURE_FROM_NEW_SECRET
Accept the delivery if any signature matches, which the example above already does. That gives you 24 hours to deploy the new secret without dropping events.
Next steps
Once you trust the payload, decide how to respond and how to handle repeats. See Delivery and retries.