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-Signaturesha256=<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;
  }
}

See also