Boulevard sends a webhook when an appointment is created or completed. Before you trust it, verify the signature. Before you do anything slow, answer it.
Short answer: read x-blvd-hmac-salt and x-blvd-hmac-sha256, compute a base64 HMAC-SHA256 of salt + ":" + raw_body with the base64-decoded secret, and compare in constant time. Acknowledge PING before verifying. Return 2xx within seconds and do the real work after responding with ctx.waitUntil.
This is the entry point of the pipeline in our complete server-side guide.
Based on our build in 2026. Boulevard's developer documentation requires an account, so verify against it before shipping.
What Boulevard sends
- Events in SCREAMING_SNAKE_CASE:
APPOINTMENT_CREATED,APPOINTMENT_COMPLETED, andPINGwhen you register the webhook. x-blvd-hmac-salt: in our build, it looked likeblvd-webhook-v1:<application id>:<timestamp>.x-blvd-hmac-sha256: the base64 signature.- A body with the event type and the appointment's ID. It doesn't include client details or money. You fetch those with the Admin API (see how to authenticate).
In our build, the signing secret was the same value as the Admin API secret, base64-encoded.
The Worker
const b64ToBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const bytesToB64 = (bytes) => btoa(String.fromCharCode(...bytes));
async function verifyBlvd(rawBody, salt, signature, secretB64) {
if (!salt || !signature || !secretB64) return false;
const key = await crypto.subtle.importKey('raw', b64ToBytes(secretB64),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${salt}:${rawBody}`));
const expected = bytesToB64(new Uint8Array(mac));
const given = signature.trim();
if (expected.length !== given.length) return false;
let diff = 0; // constant-time compare
for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ given.charCodeAt(i);
return diff === 0;
}
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname !== '/webhooks/boulevard') return new Response('Not found', { status: 404 });
if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 });
const raw = await request.text(); // verify the RAW body, before parsing
let body;
try { body = JSON.parse(raw); } catch { return new Response('Bad JSON', { status: 400 }); }
if (body.eventType === 'PING') return new Response('ok'); // ack pings first
const ok = await verifyBlvd(raw,
request.headers.get('x-blvd-hmac-salt'),
request.headers.get('x-blvd-hmac-sha256'),
env.BLVD_WEBHOOK_SECRET);
if (!ok) return new Response('Invalid signature', { status: 401 });
if (!['APPOINTMENT_CREATED', 'APPOINTMENT_COMPLETED'].includes(body.eventType)) {
return new Response('ignored'); // 2xx for events you don't use
}
ctx.waitUntil(processAppointment(body, env).catch((e) => console.error('process failed', e)));
return new Response('ok'); // answer now, work later
},
};
Why each detail matters
Verify the raw body
The signature covers the exact bytes Boulevard sent. If you parse the JSON and re-stringify it, spacing and key order can change, and the signature never matches. Read the body as text once, verify that text, then parse.
Ack PING before verifying
Boulevard sends a PING when you create the webhook. Answer it with 200 straight away. Otherwise registration can fail while you're still debugging the signature code.
Answer within seconds
Boulevard expects a 2xx quickly, within about 5 to 10 seconds in our experience, and retries otherwise. Your processing calls the Admin API and then up to five ad platforms. That can take longer than the window.
In Cloudflare Workers, ctx.waitUntil(promise) lets you return the response immediately while the promise keeps running. Elsewhere, push the job to a queue and return.
Retries mean duplicates
Because Boulevard retries, the same appointment can arrive twice. Make everything downstream idempotent:
- Derive event IDs from the appointment ID and event type, so ad platforms deduplicate.
- Optionally store a "processed" marker per appointment and event for a day, and skip repeats.
Don't let it fail for long
If your endpoint keeps failing, Boulevard can disable the subscription automatically. In our notes, that was after roughly 8 hours of repeated non-2xx responses. Bookings then stop arriving silently.
- Return 2xx for events you don't handle, instead of 4xx.
- Alert on processing failures separately from the HTTP response.
- If the subscription is disabled, re-register the webhook. We kept a small script for that.
Registering the webhook
Webhooks are created through the Admin API with a GraphQL mutation. You need the location ID first, from a locations(first: 50) { edges { node { id name } } } query:
mutation CreateWebhook($input: CreateWebhookInput!) {
createWebhook(input: $input) {
webhook { id name url subscriptions { id enabled eventType } }
}
}
{
"input": {
"locationId": "urn:blvd:Location:YOUR_LOCATION_ID",
"url": "https://YOUR_WORKER_DOMAIN/webhooks/boulevard",
"name": "Ad conversion tracking",
"subscriptions": [
{ "eventType": "APPOINTMENT_CREATED" },
{ "eventType": "APPOINTMENT_COMPLETED" }
]
}
}
Note the full urn:blvd:Location: form for the location ID here. We ran this once from a local script with the credentials filled in, then put placeholders back before saving. The response's enabled flag per subscription is also the first thing to check if webhooks stop arriving.
Common errors
- Every signature fails: you're verifying parsed and re-stringified JSON, or the secret wasn't base64-decoded.
- Webhook registration fails:
PINGisn't acknowledged with 200. - Duplicate conversions: retries with non-idempotent processing.
- Events never match: listening for
appointment.createdinstead ofAPPOINTMENT_CREATED. - Bookings stop arriving: the subscription was auto-disabled after repeated failures.
Testing checklist
- Registering the webhook succeeds (PING acknowledged).
- A request with a wrong signature gets 401.
- A real test booking verifies and processes.
- The response returns in well under a second.
- Sending the same webhook twice creates no duplicate conversions.
- You get alerted when processing fails.
Related reading: getting real revenue out of Boulevard GraphQL.

