Technical

Verifying Boulevard webhooks in a Cloudflare Worker: HMAC, retries and async processing

Boulevard signs every webhook and expects an answer within seconds. Here's the verification code, and how to keep your subscription from being switched off.

Hamza El Asri Hamza El AsriFounder & lead strategist, RM Click PublishedNovember 10, 2026 Reading time7 min read AudienceDevelopers

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, and PING when you register the webhook.
  • x-blvd-hmac-salt: in our build, it looked like blvd-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: PING isn't acknowledged with 200.
  • Duplicate conversions: retries with non-idempotent processing.
  • Events never match: listening for appointment.created instead of APPOINTMENT_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.

Hamza El Asri
Written by Hamza El Asri

Media buyer and tracking specialist, founder of RM Click. Top Rated on Fiverr with 413 reviews. We built the pipeline this article describes. LinkedIn · Fiverr

FAQ

Frequently asked
questions

x-blvd-hmac-salt and x-blvd-hmac-sha256. The signature is a base64 HMAC-SHA256 of the salt, a colon and the raw request body, keyed with the base64-decoded secret.

In our build, the webhook signing secret was the same value as the Admin API secret of the custom app.

Within a few seconds, with a 2xx. Otherwise Boulevard retries. Acknowledge first and do the slow work after responding, for example with ctx.waitUntil in Cloudflare Workers.

Sustained failures can make Boulevard disable the subscription automatically. In our notes, that was after about 8 hours of repeated non-2xx responses. Re-register the webhook to restore it.

SCREAMING_SNAKE_CASE, such as APPOINTMENT_CREATED, APPOINTMENT_COMPLETED and PING. Lowercase dotted names like appointment.created won't match.

Free ad audit

Need this set up
for your salon?