Skip to main content

Verify webhook signatures

Every webhook request carries an ob-signature header. Verify it before you trust the request: it proves that the request was sent by Onebox with the API key of your webhook, and that the body was not modified on the way.

How the signature is computed​

ob-signature = hex( SHA-256( rawBody + apiKey ) )
  1. Take the request body exactly as it was sent: the raw bytes, before any JSON parsing.
  2. Append the API key of the webhook configuration that sent it (the one identified by the ob-hook-id header).
  3. Compute the SHA-256 digest of the result, as UTF-8 bytes.
  4. Encode it as lowercase hexadecimal (64 characters).

The signature covers the body only. Headers are not signed.

Always sign the raw body

The signature is computed over the exact bytes Onebox sends. Most frameworks parse JSON bodies automatically; if you parse the body and serialize it again, spacing and line breaks change and the signature will never match. Read the raw body, verify it, and only then parse it.

Verify it in your code​

Each example reads the raw body, compares signatures in constant time, and answers quickly so that the delivery does not time out. lookupApiKey stands for however you store the API key of each webhook; if you have a single webhook, it is just that key.

const crypto = require('crypto');
const express = require('express');

const app = express();

// express.raw() keeps the body as a Buffer: do not use express.json() on this route.
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const apiKey = lookupApiKey(req.get('ob-hook-id'));
const expected = crypto.createHash('sha256')
.update(req.body)
.update(apiKey, 'utf8')
.digest('hex');
const received = req.get('ob-signature') ?? '';

const valid = received.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) {
return res.sendStatus(401);
}

enqueue(JSON.parse(req.body));
res.sendStatus(204);
});

Common mistakes​

SymptomCauseFix
Never matches, even for the first requestThe body was parsed and re-serialized before hashingHash the raw request bytes (see the examples above)
Never matches for one webhookThe key belongs to another webhook, or it was regeneratedLook up the key by ob-hook-id; after regenerating a key, update it in your system
Matches in tests, fails in productionA proxy, API gateway or middleware rewrites the bodyVerify the signature at the edge, or make the proxy pass the body through untouched
Fails only with some charactersThe key or body was hashed in a charset other than UTF-8Encode both as UTF-8
Comparison works but is flagged by security reviewPlain string comparison leaks timing informationUse a constant-time comparison, as in the examples

To debug a mismatch with real data, paste the body, the key and the received header into the signature tester: it tells you which of these mistakes explains the difference.

Security considerations​

  • Keep the API key secret. Store it like a password: it is all an attacker needs to forge a valid request. If it leaks, regenerate it (PUT /webhooks/{id}/apikey/regenerate in the Management API) and update your system.
  • Process idempotently. Receiving the same request twice must have no effect: treat the webhook as a notification to fetch the current state through the API, not as the state itself. See Delivery and retries.
  • Use an HTTPS endpoint so that bodies and signatures cannot be read in transit.