Cookbook
Handle a webhook
Receive, verify, deduplicate, and process a Bedrock webhook event end-to-end.
This recipe is a complete webhook receiver. It handles signature verification, deduplication, and idempotent processing. Adapt it to whatever framework you're using.
1. Register the webhook
bash
curl -X POST https://api.bedrockcompliance.co.uk/v1/firm/me/webhooks \
-H "X-Bedrock-Key: bk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/bedrock",
"events": ["review.completed", "document.approved", "sla.breached"]
}'The response includes a secret. Store it somewhere accessible to the receiver — you will need it to verify signatures.
2. Receive and verify
Bedrock sends two headers on every delivery:
X-Bedrock-Event— the event name (e.g.review.completed)X-Bedrock-Signature—sha256=<hex>, an HMAC-SHA256 of the raw request bytes using your firm webhook secret
The body is the payload directly — there is no envelope wrapping it. Verify against the raw bytes; do not re-serialise via JSON.stringify.
ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export async function handleBedrockWebhook(req: Request) {
const header = req.headers.get('x-bedrock-signature');
const eventName = req.headers.get('x-bedrock-event');
if (!header || !eventName) return new Response('missing signature', { status: 400 });
// Header format is "sha256=<hex>"
const [scheme, provided] = header.split('=');
if (scheme !== 'sha256' || !provided) {
return new Response('bad signature format', { status: 400 });
}
// Read the raw body — the signature is computed over the exact bytes Bedrock sent.
const raw = Buffer.from(await req.arrayBuffer());
const expected = createHmac('sha256', process.env.BEDROCK_WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response('bad signature', { status: 401 });
}
// The body IS the payload — no envelope. The shape varies by event type.
const payload = JSON.parse(raw.toString('utf8')) as Record<string, unknown>;
// Idempotent: dedupe by a stable id from the payload (e.g. ledger record id, job id).
const dedupeKey = `${eventName}:${payload.id ?? payload.reviewJobId}`;
const seen = await db.bedrockEvent.findUnique({ where: { id: dedupeKey } });
if (seen) return new Response(null, { status: 204 });
await db.bedrockEvent.create({
data: { id: dedupeKey, type: eventName, payload: raw.toString('utf8') },
});
await processEvent(eventName, payload);
return new Response(null, { status: 204 });
}3. Process idempotently
ts
async function processEvent(eventName: string, payload: any) {
switch (eventName) {
case 'review.completed':
await db.advice.update({
where: { externalRef: payload.reviewJobId },
data: { reviewedAt: new Date(), outcome: payload.outcome },
});
break;
case 'document.approved':
// Outcome events carry ledgerRecordId — use it to fetch the cert PDF
// via GET /v1/ledger/records/{id}/certificate once cert-gen has run.
await db.advice.update({
where: { externalRef: payload.reviewJobId },
data: { ledgerRecordId: payload.id },
});
break;
case 'sla.breached':
await pageOnCall(`Bedrock SLA breach on job ${payload.reviewJobId}`);
break;
}
}