Webhooks
Verifying a request
Check the signature so you know a request came from IT Bible and was not replayed.
Anyone who learns your address could send it fake events. Every real request is signed with your webhook's secret, so check the signature before you trust a request.
How the signature is made
- Take the
X-Webhook-Timestampheader and the raw request body, exactly as received. - Join them with a dot:
{timestamp}.{body}. - Compute an HMAC with SHA-256 over that text, using your webhook's signing secret as the key.
- Write the result as lowercase hexadecimal and put
sha256=in front.
The result must equal the X-Webhook-Signature header. Compare the two with a constant-time comparison, not ==.
Also reject a request whose timestamp is more than 5 minutes away from your clock. That stops someone from replaying an old request they captured.
The body you check must be the raw bytes. If your framework parses the JSON first and you serialize it again, whitespace can change and the signature will not match. Read the raw body.
Finding your secret
The secret is shown on the webhook's page, and you can replace it there. Replacing it takes effect at once, so update your server first or accept both secrets for a few minutes. Keep it out of source control.
PHP
$secret = getenv('ITBIBLE_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);
if (! hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
http_response_code(204);
JavaScript (Node)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Keep the raw body: the signature is made from the exact bytes.
app.post('/itbible', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Webhook-Timestamp') ?? '';
const signature = req.get('X-Webhook-Signature') ?? '';
const expected = 'sha256=' + crypto.createHmac('sha256', process.env.ITBIBLE_WEBHOOK_SECRET).update(`${timestamp}.${req.body}`).digest('hex');
const valid = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString());
res.sendStatus(204);
});
Python
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/itbible")
def itbible():
body = request.get_data()
timestamp = request.headers.get("X-Webhook-Timestamp", "")
signature = request.headers.get("X-Webhook-Signature", "")
signed = timestamp.encode() + b"." + body
expected = "sha256=" + hmac.new(os.environ["ITBIBLE_WEBHOOK_SECRET"].encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature) or abs(time.time() - int(timestamp or 0)) > 300:
abort(401)
event = request.get_json()
return "", 204
Checking your code
Use Send a test event on the webhook's page. It sends a ping event signed the same way as a real one, so you can confirm your check passes before anything real happens.