Skip to navigation

Webhooks

Signed events when orders and reservations change

LettBestilt sends an HTTPS POST to your endpoint when something happens to a restaurant’s orders or reservations. Use webhooks for your own systems, such as a back office, a CRM or notifications, instead of polling.

Getting a subscription

Webhooks are set up per restaurant by LettBestilt. Tell us:

  • the URL. It must be a public https:// address.
  • which events you want.

You then receive a signing secret, starting with whsec_. It is shown only once, so store it as a secret on your server.

Events

EventWhen
ORDER_CREATEDA new order is created. Online orders arrive as AWAITING_PAYMENT, cash orders as CONFIRMED
ORDER_PAIDThe online payment is confirmed, and the order goes to the kitchen
ORDER_STATUS_CHANGEDThe restaurant moves the order to a new status
ORDER_CANCELLEDThe restaurant cancels the order
RESERVATION_REQUESTEDA guest requests a table
RESERVATION_APPROVEDThe restaurant approves the request and assigns a table
RESERVATION_REJECTEDThe restaurant rejects the request

There is no event for a payment that fails or is abandoned. Such an order never gets ORDER_PAID.

Payload

Every event has the same envelope:

{
"event": "ORDER_STATUS_CHANGED",
"createdAt": "2026-10-16T15:20:05.000Z",
"data": {
"orderId": "cm2ord0001abcdefghijklmn",
"orderNumber": 1042,
"publicToken": "cm2tok0001abcdefghijklmn",
"previousStatus": "PREPARING",
"status": "READY_FOR_PICKUP"
}
}

data always has orderId, orderNumber and publicToken. The rest depends on the event:

EventAlso in data
ORDER_CREATEDstatus, total, fulfillment, paymentMethod, estimatedReadyAt, requestedPickupAt
ORDER_PAIDstatus (PAID), total
ORDER_STATUS_CHANGED, ORDER_CANCELLEDpreviousStatus, status
RESERVATION_REQUESTEDdineInAt, partySize
RESERVATION_APPROVEDdineInAt, partySize, tableId
RESERVATION_REJECTEDreason (or null)

Times are ISO 8601 in UTC. For reservations, orderId is the same value as reservationId from POST /reservations. Need more than the payload? Fetch the order with GET /orders/{publicToken}.

Headers

HeaderMeaning
X-LettBestilt-Webhook-IdThe webhook subscription
X-LettBestilt-Delivery-IdStable per event. Retries of the same event carry the same ID
X-LettBestilt-Webhook-TimestampUnix time in seconds when this attempt was signed
X-LettBestilt-Webhook-Signaturesha256= followed by the hex HMAC

Verify the signature

The signature is an HMAC-SHA256 of {timestamp}.{raw body}, keyed with your signing secret and hex-encoded. Verify it before you trust the payload, and compute it over the raw request body, not over JSON you have parsed and serialized again.

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLettBestiltWebhook(
rawBody: string,
timestamp: string,
signatureHeader: string,
secret: string,
): boolean {
const expected = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const received = signatureHeader.replace(/^sha256=/, "");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
// Reject old timestamps to stop replays (5 minutes here).
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
return ageSeconds < 300;
}

A complete handler, for example a Next.js route:

app/api/lettbestilt-webhook/route.ts
import { verifyLettBestiltWebhook } from "./verify";
export async function POST(request: Request) {
const rawBody = await request.text();
const ok = verifyLettBestiltWebhook(
rawBody,
request.headers.get("x-lettbestilt-webhook-timestamp") ?? "",
request.headers.get("x-lettbestilt-webhook-signature") ?? "",
process.env.LETTBESTILT_WEBHOOK_SECRET!,
);
if (!ok) return new Response("invalid signature", { status: 401 });
const deliveryId = request.headers.get("x-lettbestilt-delivery-id")!;
if (await alreadyHandled(deliveryId)) return new Response(null, { status: 204 });
const { event, createdAt, data } = JSON.parse(rawBody);
await enqueue({ deliveryId, event, createdAt, data }); // do the real work later
return new Response(null, { status: 204 });
}

Answering 401 to a bad signature stops retries for that delivery, which is what you want.

Delivery and retries

  • Respond with any 2xx within 5 seconds. Do slow work after you respond.
  • Redirects are not followed. Answer from the URL you registered.
  • 5xx, 408, 429, redirects, timeouts and network errors count as temporary. LettBestilt tries up to 7 times in total: right away, then 1 minute, 5 minutes, 15 minutes, 1 hour, 4 hours and 12 hours after the previous attempt (about 17 hours in all). After the seventh failure the delivery is given up.
  • Any other 4xx is treated as permanent, and the delivery is not retried.
  • Deliveries can arrive more than once and out of order. Skip X-LettBestilt-Delivery-Id values you have already handled, and use createdAt to order events.