New: three demo venues, live — a restaurant, a hotel and a pub. Explore the live demo

For developers

Webhooks that tell you
exactly what happened.

Nine events. One envelope. An HMAC you can verify. Point them at your own endpoint, or at Zapier, Make or n8n.

Point Carte at your endpoint

  1. In your admin, open Integrations and choose Webhooks for your own endpoint — or Zapier, Make or n8n for those platforms' catch hooks.
  2. Paste your URL. It must be a valid absolute <code>http://</code> or <code>https://</code> URL; anything else is ignored silently rather than saved and quietly never fired.
  3. Set a signing secret. On the Webhooks connector this is expected; on Zapier, Make and n8n it is optional — leave it blank and deliveries go unsigned.
  4. Press Test connection. That sends a real <code>ping</code> event to your URL right now — the fastest way to prove your route and your signature check work before real traffic arrives.
  5. Configure as many as you like. Every configured target receives every event: there is no per-event subscription and no filtering. Ignore what you don't need by switching on <code>event</code>.
Signed for your own endpoint; optional on the automation platforms. The Webhooks connector expects a signing secret and signs every delivery with it. Zapier, Make and n8n take a secret too, but it is optional — those platforms authenticate with an unguessable catch-hook URL, and most people never verify an HMAC on them. Leave the secret blank and those deliveries are unsigned: no X-Carte-Signature header is sent at all (an empty HMAC is never sent in its place). If your automation does anything consequential, set a secret and check it.

The envelope

Every event — all nine — is a POST with Content-Type: application/json, User-Agent: Carte-Webhook/1.0, and this outer shape. Only data changes. sent is an ISO-8601 timestamp in the server's timezone.

POST https://your-endpoint.example/hooks/carte
{
  "event": "menu.published",
  "venue": { "slug": "the-copper-pot", "name": "The Copper Pot" },
  "data":  { "menu": { "id": "mdinner01", "name": "Dinner — Spring" } },
  "sent":  "2026-07-16T09:12:00+00:00"
}

The nine events

Every configured target receives every event — there is no subscription model. Switch on event and ignore the rest. Each block below shows the data object only; wrap it in the envelope above.

menu.published api

A menu is switched from draft to live. From that moment it is visible on GET /menus, the QR page and every website embed.

"data" for menu.published
{ "menu": { "id": "mdinner01", "name": "Dinner — Spring" } }
menu.unpublished api

A menu is switched from live back to draft. Same call site as menu.published — which of the two fires is decided by the new status.

"data" for menu.unpublished
{ "menu": { "id": "mdinner01", "name": "Dinner — Spring" } }
dish.stock api

A dish is 86'd or restored in the editor. "available" is the NEW state: false means it has just come off. Fires on every toggle, including a toggle back within seconds — there is no debounce.

"data" for dish.stock
{
  "dish": { "id": "d7321b40e4b", "name": "Cured chalk-stream trout", "available": false },
  "menu": { "id": "mdinner01", "name": "Dinner — Spring" }
}
booking.created booking-api v1 api

A booking is made. Fires from all three origins — the public widget, the REST API, and a staff member adding one by hand — with an identical payload; tell them apart with "source".

"data" for booking.created
{
  "booking": {
    "ref": "BK-YSXGBS",
    "status": "confirmed",
    "source": "widget",
    "date": "2026-08-14",
    "party": 2
  }
}
booking.updated api

A booking's status changes in the admin — confirmed or cancelled. Note the shape differs from booking.created: it carries "guest" instead of "date" and "party". That asymmetry is real and is documented rather than hidden.

"data" for booking.updated
{
  "booking": { "ref": "BK-YSXGBS", "status": "cancelled", "guest": "Ana Petrova" }
}
voucher.issued voucher

A gift voucher becomes active — either issued active outright, or a pending voucher flipped to active once its payment settles. A voucher issued as "pending" fires nothing until it activates. "value" is the face value; a new voucher's balance always equals it.

"data" for voucher.issued
{ "voucher": { "code": "GV-4K2P-9QX7", "value": 50 } }
voucher.redeemed voucher

An amount is taken off a voucher, from the admin or the API. Fires on every partial redemption. "balance" is what is left afterwards — when it reaches 0 the voucher's status becomes "redeemed", but no separate event marks that.

"data" for voucher.redeemed
{ "voucher": { "code": "GV-4K2P-9QX7", "amount": 20, "balance": 30 } }
ping inbound-connector

You pressed "Test connection" on a Zapier, Make, n8n or Webhooks panel. Nothing in the venue changed. Use it to prove the endpoint is reachable and your signature check works before real traffic arrives.

"data" for ping
{ "message": "Carte connection test" }
menu.snapshot inbound-connector

You pressed "Sync" on a Zapier, Make, n8n or Webhooks panel. Not an event — a full dump of every live dish and stocked wine as flat items, pushed on demand. Wine names arrive pre-formatted as "Bin 34 — Meursault 2021" and priced by the bottle. This is a snapshot, never automatic: nothing schedules it.

"data" for menu.snapshot
{
  "items": [
    { "id": "d7321b40e4b", "name": "Cured chalk-stream trout", "desc": "pickled fennel, buttermilk, dill oil", "price": 12.5 },
    { "id": "w173b9cb432", "name": "Bin 34 — Meursault 2021", "desc": "Domaine Roulot · White — taut, mineral", "price": 86 }
  ]
}

Verifying the signature

When a signing secret is set, Carte sends an <code>X-Carte-Signature</code> header: the string <code>sha256=</code> followed by the hex HMAC-SHA256 of the raw request body, keyed with your secret. Verify it on the bytes you received — parsing the JSON and re-encoding it changes whitespace and key order, and the HMAC will not match. Compare in constant time.

Header
X-Carte-Signature: sha256=3f9a1c2b8e...
PHP
<?php
// Your endpoint. Read the RAW body — not $_POST, not a re-encoded array.
$raw    = file_get_contents( 'php://input' );
$header = $_SERVER['HTTP_X_CARTE_SIGNATURE'] ?? '';
$secret = getenv( 'CARTE_WEBHOOK_SECRET' );

$expected = 'sha256=' . hash_hmac( 'sha256', $raw, $secret );

// hash_equals, not === : constant time, so a timing attack cannot walk the HMAC.
if ( ! hash_equals( $expected, $header ) ) {
    http_response_code( 401 );
    exit;
}

// Acknowledge FIRST — you have 5 seconds. Queue the work, do not do it here.
http_response_code( 200 );

$event = json_decode( $raw, true );
switch ( $event['event'] ) {
    case 'menu.published':
        // $event['data']['menu']['id']
        break;
    case 'booking.created':
        // ref, status, source, date, party — the same fields from every origin
        break;
}
Node — Express
const crypto = require('crypto');

// express: mount the RAW body, or the HMAC will never match.
app.post('/hooks/carte', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.CARTE_WEBHOOK_SECRET)
    .update(req.body)                       // Buffer, exactly as received
    .digest('hex');

  const got = req.get('X-Carte-Signature') || '';
  const ok  = expected.length === got.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got));

  if (!ok) return res.sendStatus(401);
  res.sendStatus(200);                      // acknowledge first

  const event = JSON.parse(req.body.toString('utf8'));
  queue.push(event);                        // then do the work
});
Python — Flask
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/hooks/carte")
def carte_hook():
    raw = request.get_data()               # bytes, exactly as received
    expected = "sha256=" + hmac.new(
        os.environ["CARTE_WEBHOOK_SECRET"].encode(),
        raw,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, request.headers.get("X-Carte-Signature", "")):
        abort(401)

    queue.push(request.get_json())         # acknowledge by returning fast
    return "", 200

Delivery guarantees, stated honestly

Delivery is best-effort by design. Read this before you build anything that must not miss an event.

  • No retries. Every event is delivered exactly once, attempted once. If your endpoint is down, restarting, or returns a 500, that event is gone — Carte does not try again and does not queue it. Anything that must not be missed should be reconciled against the REST API on a schedule, not driven by webhooks alone.
  • Short timeouts. 3 seconds to connect, 5 seconds total. Acknowledge fast and do the work afterwards; if you process inline and take six seconds, the delivery is recorded as failed even though you handled it.
  • Any 2xx is success. Anything else — including a 3xx — is logged as an error. Redirects are not followed.
  • Delivery is synchronous. Events fire inside the request that caused them, so a slow endpoint of yours makes a member of staff wait for their menu to publish. There is no queue and no background worker.
  • No ordering guarantee, no delivery ids. Events carry no sequence number and no unique id. Two rapid changes can arrive out of order. Use <code>sent</code> and your own idempotency key if that matters to you.
  • Every attempt is logged. Success or failure, with the HTTP status, in your Integrations sync log — the first place to look when an automation goes quiet.
One quirk worth knowing on booking.created. source tells you where the booking came from — widget, api or admin. A booking created through the REST API reports "source": "api" in this event, but is stored against the venue as widget, because the stored column only accepts widget, admin or demo. So the event is more precise than the record it describes. The webhook value is the one to trust for routing; we would rather tell you than let you find the discrepancy yourself.

Keep reading

Service starts now

Tonight's menu,
everywhere at once.

Start your 14-day free trial. No card required, no designer round-trips, no stale PDFs.

14 days free  ·  Cancel anytime  ·  Set up in an afternoon

See it live

Three demo venues are running right now.

A restaurant, a hotel and a pub, each built on Carte. The booking widgets on them are real — book a table and the booking lands in the admin behind it, which you can open too.

Open the demo admin

Your own private copy of the admin, built in one click. No signup, no card.