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 ) )
- Take the request body exactly as it was sent: the raw bytes, before any JSON parsing.
- Append the API key of the webhook configuration that sent it (the one identified by the
ob-hook-idheader). - Compute the SHA-256 digest of the result, as UTF-8 bytes.
- Encode it as lowercase hexadecimal (64 characters).
The signature covers the body only. Headers are not signed.
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.
- Node.js (Express)
- Python (Flask)
- Java (Spring)
- PHP
- C# (ASP.NET Core)
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);
});
import hashlib
import hmac
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhooks")
def receive_webhook():
api_key = lookup_api_key(request.headers.get("ob-hook-id"))
raw_body = request.get_data() # raw bytes, before any JSON parsing
expected = hashlib.sha256(raw_body + api_key.encode("utf-8")).hexdigest()
received = request.headers.get("ob-signature", "")
if not hmac.compare_digest(received.encode(), expected.encode()):
abort(401)
enqueue(request.get_json())
return "", 204
@RestController
public class WebhookController {
// byte[] keeps the body exactly as received: do not bind it to a DTO here.
@PostMapping("/webhooks")
public ResponseEntity<Void> receive(@RequestHeader("ob-hook-id") String hookId,
@RequestHeader("ob-signature") String signature,
@RequestBody byte[] rawBody) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(rawBody);
digest.update(lookupApiKey(hookId).getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(digest.digest());
if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII),
signature.getBytes(StandardCharsets.US_ASCII))) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
enqueue(rawBody);
return ResponseEntity.noContent().build();
}
}
<?php
$rawBody = file_get_contents('php://input');
$apiKey = lookupApiKey($_SERVER['HTTP_OB_HOOK_ID'] ?? '');
$expected = hash('sha256', $rawBody . $apiKey);
$received = $_SERVER['HTTP_OB_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
enqueue(json_decode($rawBody, true));
http_response_code(204);
app.MapPost("/webhooks", async (HttpRequest request) =>
{
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
byte[] rawBody = buffer.ToArray();
byte[] apiKey = Encoding.UTF8.GetBytes(LookupApiKey(request.Headers["ob-hook-id"].ToString()));
string expected = Convert.ToHexString(SHA256.HashData([.. rawBody, .. apiKey])).ToLowerInvariant();
string received = request.Headers["ob-signature"].ToString();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(received)))
{
return Results.Unauthorized();
}
Enqueue(rawBody);
return Results.NoContent();
});
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Never matches, even for the first request | The body was parsed and re-serialized before hashing | Hash the raw request bytes (see the examples above) |
| Never matches for one webhook | The key belongs to another webhook, or it was regenerated | Look up the key by ob-hook-id; after regenerating a key, update it in your system |
| Matches in tests, fails in production | A proxy, API gateway or middleware rewrites the body | Verify the signature at the edge, or make the proxy pass the body through untouched |
| Fails only with some characters | The key or body was hashed in a charset other than UTF-8 | Encode both as UTF-8 |
| Comparison works but is flagged by security review | Plain string comparison leaks timing information | Use 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/regeneratein 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.