Point Carte at your endpoint
- In your admin, open Integrations and choose Webhooks for your own endpoint — or Zapier, Make or n8n for those platforms' catch hooks.
- 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.
- 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.
- 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.
- 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>.
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.
{
"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.
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.
{
"dish": { "id": "d7321b40e4b", "name": "Cured chalk-stream trout", "available": false },
"menu": { "id": "mdinner01", "name": "Dinner — Spring" }
}
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".
{
"booking": {
"ref": "BK-YSXGBS",
"status": "confirmed",
"source": "widget",
"date": "2026-08-14",
"party": 2
}
}
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.
{
"booking": { "ref": "BK-YSXGBS", "status": "cancelled", "guest": "Ana Petrova" }
}
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.
{ "voucher": { "code": "GV-4K2P-9QX7", "value": 50 } }
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.
{ "voucher": { "code": "GV-4K2P-9QX7", "amount": 20, "balance": 30 } }
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.
{ "message": "Carte connection test" }
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.
X-Carte-Signature: sha256=3f9a1c2b8e...
<?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;
}
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
});
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.
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
- REST API reference — the nine endpoints, and how to reconcile what a missed webhook lost.
- Integrations reference — every connector, live or simulated.