Webhooks
ServeU sends an HTTPS POST to your URL the moment something happens at the business — a new order, a stamp, an item selling out — so your POS, kitchen screen or CRM doesn't have to poll. Each request is signed with Standard Webhooks, so you can use their libraries to check it.
- An endpoint (your URL) subscribes to some of the 14 event types, or to all of them (
["*"], which also includes types added later). A business can have up to 10 endpoints. - Delivery is at least once: the same event can arrive twice, and events can arrive out of order. See Duplicates and order.
1. Set up an endpoint
In the dashboard (the owner): Settings → Developers → Webhooks → Add endpoint. Enter the URL, pick the events, optionally limit it to one branch, and leave Include contact details off unless the system needs the customer's name and phone. The signing secret (whsec_…) is shown when the endpoint is created; the owner can show it again later with Reveal and send it to you privately.
Through the API: with a key that has webhooks:manage and is not limited to one branch (a branch-limited key gets 403 forbidden). To limit the endpoint to one branch, send its branch_id.
curl -X POST https://serveu-psi.vercel.app/api/v1/webhook-endpoints -H "Authorization: Bearer serveu_live_XXXX…" \
-H "Content-Type: application/json" \
-d '{"url":"https://pos.example.com/serveu/webhooks","description":"Olaya POS","events":["order.created","order.status_changed"]}'The answer (201) is the endpoint with its secret. The API shows it only in this answer (and when you rotate it): store it in your secrets right away.
{
"id": "b2d4f6a8-c0e2-4a4b-8c6d-8e0a2c4e6a8b",
"url": "https://pos.example.com/serveu/webhooks",
"description": "Olaya POS",
"events": [
"order.created",
"order.status_changed"
],
"branch_id": null,
"include_contact": false,
"status": "active",
"pause_reason": null,
"failing_since": null,
"paused_at": null,
"secret_rotating_until": null,
"created_at": "2026-11-08T09:41:12.482Z",
"updated_at": "2026-11-08T09:41:12.482Z",
"secret": "whsec_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
}- Creating an endpoint and rotating its secret ignore
Idempotency-Key(the answer holds a secret, which ServeU never stores). If a retry created a second endpoint, delete the extra one. include_contact: truethrough the API also needs thecustomers:contactpermission.- Changing an endpoint's events or branch applies to new events only: deliveries already queued for it (waiting for a retry, or paused) are not removed, and still go out as they were.
- The URL must be
https://on port 443 or 8443, to a public host name or public IP address: no username or password in the URL, no private or internal addresses (a host name is checked with a DNS lookup when you save it and before every request). Redirects are not followed.
2. The request
| Header | Value |
|---|---|
content-type | application/json |
user-agent | ServeU-Webhooks/1 |
webhook-id | The event id (evt_…), the same on every retry and resend: use it to de-duplicate. |
webhook-timestamp | When this attempt was sent, in Unix seconds. |
webhook-signature | v1,<base64>. While a secret is rotating, two signatures separated by a space. |
The body is one JSON event (the id is the same as webhook-id):
id,type(e.g.order.created).created_at: when it happened (ISO 8601, UTC). Not when it was sent.business_id, andbranch_id, which isnullfor events that belong to no branch.data.object: the order, card or menu item as the API returns it (the same fields asGET /api/v1/orders/{id}and friends). Some events add fields next to it: see the event reference.
3. Verify the signature
Always verify before trusting a request: anyone can send a POST to your URL. The rules (Standard Webhooks, version v1):
- The signed content is
{webhook-id}.{webhook-timestamp}.{raw body}. - The key is the base64-decoded bytes after
whsec_; the signature is HMAC-SHA256, base64-encoded. - Reject a timestamp more than 5 minutes from your clock (a replayed request).
- Compare in constant time, and accept if any signature in the header matches (there are two while a secret is rotating).
Webhook-Id.Node — with the Standard Webhooks library
npm install standardwebhooks express
import express from "express";
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.SERVEU_WEBHOOK_SECRET); // "whsec_…"
const app = express();
app.post("/serveu/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = wh.verify(req.body, req.headers); // throws if the signature or timestamp is wrong
} catch {
return res.status(400).end();
}
// De-duplicate on event.id, store it, answer fast, process later.
res.status(204).end();
});
app.listen(3000);
Node — no library
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string or a Buffer).
// headers: lower-case names, as Node gives them. secret: "whsec_…".
export function verifyServeU(rawBody, headers, secret) {
const id = headers["webhook-id"], ts = headers["webhook-timestamp"], sigs = headers["webhook-signature"];
if (!id || !ts || !sigs || !/^\d+$/.test(ts)) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // 5 minutes
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64"));
return sigs.split(" ").some((s) => {
const [version, value] = s.split(",");
const got = Buffer.from(value ?? "");
return version === "v1" && got.length === expected.length && timingSafeEqual(got, expected);
});
}
Python — with the Standard Webhooks library
pip install standardwebhooks flask
import os
from flask import Flask, request, abort
from standardwebhooks import Webhook
wh = Webhook(os.environ["SERVEU_WEBHOOK_SECRET"])
app = Flask(__name__)
@app.post("/serveu/webhooks")
def serveu_webhooks():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except Exception:
abort(400)
# de-duplicate on event["id"], then process
return "", 204
Python — standard library only
import base64, hashlib, hmac, time
def verify_serveu(raw_body: bytes, headers, secret: str) -> bool:
h = {k.lower(): v for k, v in headers.items()}
msg_id, ts, sigs = h.get("webhook-id"), h.get("webhook-timestamp"), h.get("webhook-signature")
if not msg_id or not ts or not sigs or not (ts.isascii() and ts.isdigit()):
return False
if abs(time.time() - int(ts)) > 300: # 5 minutes
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{ts}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
version == "v1" and hmac.compare_digest(value.encode(), expected.encode())
for version, _, value in (s.partition(",") for s in sigs.split(" "))
)
PHP — with the Standard Webhooks library
The PHP library reads lower-case header names (webhook-id…), and getallheaders() keeps the case the request used, so lower-case them first, as below.
<?php
// composer require standard-webhooks/standard-webhooks
require __DIR__ . '/vendor/autoload.php';
$wh = new \StandardWebhooks\Webhook(getenv('SERVEU_WEBHOOK_SECRET'));
$payload = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER); // the library reads lower-case names
try {
$event = $wh->verify($payload, $headers);
} catch (\Exception $e) {
http_response_code(400);
exit;
}
// de-duplicate on $event['id'], then process
http_response_code(204);
For other languages, see the libraries listed on standardwebhooks.com, or follow the four rules above.
4. Answer quickly
- Any
2xxwithin 15 seconds is success. Anything else is a failure: other status codes,3xx(redirects are not followed), timeouts and connection errors. - Store the event and answer at once; do slow work (printing, syncing) afterwards.
- Answer
2xxto event types you don't handle too (such as a type added later to a["*"]endpoint), or ServeU keeps retrying them. A testpingis never retried: another answer just shows as a failed test. - Keep your answer short and free of personal data: the delivery log keeps its first 1 KB for 30 days so the owner can see what went wrong.
- Answer
410 Goneonly if you want ServeU to stop: the endpoint is paused at once, without retries, and the owner sees that your system asked to stop.
5. Retries and pausing
A failed delivery is tried again on this schedule, each wait counted from the attempt before:
| Attempt | Sent |
|---|---|
| 1 | Immediately |
| 2 | 1 min after attempt 1 |
| 3 | 5 min after attempt 2 |
| 4 | 30 min after attempt 3 |
| 5 | 1 h after attempt 4 |
| 6 | 2 h after attempt 5 |
| 7 | 4 h after attempt 6 |
| 8 | 8 h after attempt 7 |
| 9 | 12 h after attempt 8 |
- That is 9 attempts over about 27.6 hours. Then the delivery is marked failed. Times are the earliest a retry is sent. Retries go out on schedule where ServeU's every-minute delivery job is set up; otherwise a due retry goes out with the business's next activity or the daily run, so it can be later.
- If deliveries to an endpoint keep failing for 3 days with no success in between — or your system answers
410 Gone— ServeU pauses the endpoint and the owner sees a banner saying which. A test event never counts. - While an endpoint is paused, new events are still kept for it as paused deliveries, but nothing is sent. After you fix your system, turn the endpoint back on (the dashboard, or
PATCH /api/v1/webhook-endpoints/{id}with{"status":"active"}); paused deliveries are not replayed by themselves: the owner resends them from the delivery log. - An endpoint the owner (or your system) turned off records nothing until it's turned on again. Deliveries still waiting when it was turned off are kept as paused, and are not replayed by themselves either: resend the ones you need after turning it back on.
- While the business is suspended, or ServeU has turned its API access off, no events are recorded and nothing is sent. Catch up afterwards with an
updated_sincesync (see Pagination, filtering & sync). - A delivery still waiting after 30 days is dropped.
6. Duplicates and order
- The same event can arrive more than once (a retry after a timeout, a resend from the delivery log). De-duplicate on
id(=webhook-id), and mark an id as done only once you have processed it. - Events can arrive out of order. For an order, compare
data.object.updated_atwith the one you hold and ignore older ones, or fetch the latest withGET /api/v1/orders/{id}. Cards have noupdated_at: when the order matters, fetch the card withGET /api/v1/cards/{id}. - Webhooks tell you quickly; they aren't a full record. If you must not miss anything, also run a slow
updated_sincesync now and then.
7. Branches
- An endpoint limited to one branch receives that branch's events, plus events that belong to no branch:
card.created,customer.data_deleted,ping, and corrections the owner makes in the dashboard. menu.item_availability_changedlists only its own branch indata.object.branches.stock.changedandstock.depletedbelong to the branch whose count moved.- Managing endpoints through the API needs a key for all branches.
8. Contact details and privacy
- Events never carry the customer's phone number or name unless the endpoint has Include contact details on. Then orders get
customer.nameandcustomer.phone, and cards (also in loyalty events) get acustomerobject, as with thecustomers:contactpermission. - A customer is identified at the business by their card (
card_id). There is no ServeU-wide customer id. Card numbers are always masked. - Contact details are looked up when each attempt is sent, not when the event happened. So once a customer deletes their data, they are no longer sent, and orders still waiting to be sent go out without their car details, notes or customer — like
GET /api/v1/orders/{id}after the deletion. Card and loyalty events of the deleted card that were still waiting are not sent at all. - On
customer.data_deleted(data.object.card_id), delete what you hold about that customer and card. The PDPL requires it; see Data & privacy. - An endpoint with Include contact details on should also subscribe to
customer.data_deleted(or to all events): it is how your system learns that it must delete the names and phone numbers it received.
9. Test events
Send test event in the dashboard, or POST /api/v1/webhook-endpoints/{id}/test, sends a signed ping to that endpoint only, at once. The API answers with the result:
{
"delivery_id": "c4e6a8b0-d2f4-4c6e-8a0b-2d4f6a8c0e2f",
"ok": true,
"status_code": 204,
"error": null
}okistruewhen your endpoint answered2xx;status_codeis its answer (nullif there was none) anderrorsays what went wrong (e.g.http_500,timeout,dns_failed).error: "not_sent"means a background pass picked the test event up first and sent it: check the delivery log for the result (delivery_id).
The ping your endpoint receives:
{
"id": "evt_00000000000040008000000000000041",
"type": "ping",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": null,
"data": {
"object": {
"message": "Test event from ServeU"
}
}
}- It also works on a paused endpoint, to check it before turning it back on (a turned-off endpoint answers
409 endpoint_inactive). - A test event is never retried and never pauses the endpoint, not even a
410answer; a successful one doesn't reset the 3-day failure count either. - Through the API, at most 10 test events per endpoint per minute (then
429 rate_limited).
10. Rotating the secret
- Rotate secret in the dashboard, or
POST /api/v1/webhook-endpoints/{id}/rotate-secret(the answer holds the new secret). - For 24 hours every request carries two signatures, one with each secret, and
secret_rotating_untilsays until when. Put the new secret in your system within that window; the samples above accept either. - Rotating again within those 24 hours retires the oldest secret at once.
11. The delivery log
Settings → Developers → Webhooks → an endpoint shows its deliveries of the last 30 days: every attempt with its time, status code, duration and the first 1 KB of your answer, and the request body (shown without contact details). The owner can Resend a delivery: it is sent again now, with the same webhook-id and a fresh retry schedule. The delivery log and Resend are in the dashboard only; the API has no endpoint for them.
12. Event reference
Every example below was produced by ServeU itself (ids and times replaced). Order, card and menu objects have the same fields as in the API reference.
order.created
An order reaches the counter. Pay-now orders arrive only after the online payment is confirmed.
{
"id": "evt_00000000000040008000000000000001",
"type": "order.created",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000004",
"number": "A-001",
"branch_id": "00000000-0000-4000-8000-000000000003",
"type": "dine_in",
"status": "new",
"table": {
"label": "T7"
},
"car": null,
"note": null,
"reject_reason": null,
"items": [
{
"id": "00000000-0000-4000-8000-000000000005",
"item_id": "00000000-0000-4000-8000-000000000006",
"name": {
"ar": "سبانش لاتيه",
"en": "Spanish latte"
},
"size": {
"ar": "وسط",
"en": "Medium"
},
"options": [
{
"name": {
"ar": "كامل الدسم",
"en": "Full fat"
},
"price_delta": 0
}
],
"quantity": 1,
"unit_price": 2100,
"line_total": 2100,
"note": null
}
],
"subtotal": 2100,
"vat_amount": 274,
"total": 2100,
"vat_rate": 15,
"prices_include_vat": true,
"currency": "SAR",
"paid": null,
"online_payment": null,
"loyalty_earned": null,
"customer": {
"card_id": "00000000-0000-4000-8000-000000000007"
},
"created_at": "2026-11-08T09:00:00.000Z",
"acknowledged_at": null,
"started_at": null,
"ready_at": null,
"served_at": null,
"rejected_at": null,
"updated_at": "2026-11-08T09:00:00.000Z"
}
}
}order.status_changed
An order moves to preparing, ready or served, or is rejected — from the iPad or the API. Undoing "served" sends it again with status ready. data.previous_status is the status before.
{
"id": "evt_00000000000040008000000000000008",
"type": "order.status_changed",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000004",
"number": "A-001",
"branch_id": "00000000-0000-4000-8000-000000000003",
"type": "dine_in",
"status": "preparing",
"table": {
"label": "T7"
},
"car": null,
"note": null,
"reject_reason": null,
"items": [
{
"id": "00000000-0000-4000-8000-000000000005",
"item_id": "00000000-0000-4000-8000-000000000006",
"name": {
"ar": "سبانش لاتيه",
"en": "Spanish latte"
},
"size": {
"ar": "وسط",
"en": "Medium"
},
"options": [
{
"name": {
"ar": "كامل الدسم",
"en": "Full fat"
},
"price_delta": 0
}
],
"quantity": 1,
"unit_price": 2100,
"line_total": 2100,
"note": null
}
],
"subtotal": 2100,
"vat_amount": 274,
"total": 2100,
"vat_rate": 15,
"prices_include_vat": true,
"currency": "SAR",
"paid": null,
"online_payment": null,
"loyalty_earned": null,
"customer": {
"card_id": "00000000-0000-4000-8000-000000000007"
},
"created_at": "2026-11-08T09:00:00.000Z",
"acknowledged_at": "2026-11-08T09:00:00.000Z",
"started_at": "2026-11-08T09:00:00.000Z",
"ready_at": null,
"served_at": null,
"rejected_at": null,
"updated_at": "2026-11-08T09:00:00.000Z"
},
"previous_status": "new"
}
}order.paid
An order is marked paid at the counter, or paid online (then it is sent right after order.created).
{
"id": "evt_00000000000040008000000000000012",
"type": "order.paid",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000013",
"number": "A-002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"type": "pickup",
"status": "new",
"table": null,
"car": null,
"note": null,
"reject_reason": null,
"items": [
{
"id": "00000000-0000-4000-8000-000000000014",
"item_id": "00000000-0000-4000-8000-000000000006",
"name": {
"ar": "سبانش لاتيه",
"en": "Spanish latte"
},
"size": {
"ar": "وسط",
"en": "Medium"
},
"options": [
{
"name": {
"ar": "كامل الدسم",
"en": "Full fat"
},
"price_delta": 0
}
],
"quantity": 1,
"unit_price": 2100,
"line_total": 2100,
"note": null
}
],
"subtotal": 2100,
"vat_amount": 274,
"total": 2100,
"vat_rate": 15,
"prices_include_vat": true,
"currency": "SAR",
"paid": {
"at": "2026-11-08T09:00:00.000Z",
"method": "online"
},
"online_payment": {
"status": "paid",
"method": "mada",
"mode": "test",
"late": false
},
"loyalty_earned": null,
"customer": {
"card_id": "00000000-0000-4000-8000-000000000007"
},
"created_at": "2026-11-08T09:00:00.000Z",
"acknowledged_at": null,
"started_at": null,
"ready_at": null,
"served_at": null,
"rejected_at": null,
"updated_at": "2026-11-08T09:00:00.000Z"
}
}
}order.refunded
An online payment is refunded in full (for example after staff reject a paid order).
{
"id": "evt_00000000000040008000000000000015",
"type": "order.refunded",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000013",
"number": "A-002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"type": "pickup",
"status": "rejected",
"table": null,
"car": null,
"note": null,
"reject_reason": "item_unavailable",
"items": [
{
"id": "00000000-0000-4000-8000-000000000014",
"item_id": "00000000-0000-4000-8000-000000000006",
"name": {
"ar": "سبانش لاتيه",
"en": "Spanish latte"
},
"size": {
"ar": "وسط",
"en": "Medium"
},
"options": [
{
"name": {
"ar": "كامل الدسم",
"en": "Full fat"
},
"price_delta": 0
}
],
"quantity": 1,
"unit_price": 2100,
"line_total": 2100,
"note": null
}
],
"subtotal": 2100,
"vat_amount": 274,
"total": 2100,
"vat_rate": 15,
"prices_include_vat": true,
"currency": "SAR",
"paid": {
"at": "2026-11-08T09:00:00.000Z",
"method": "online"
},
"online_payment": {
"status": "refunded",
"method": "mada",
"mode": "test",
"late": false
},
"loyalty_earned": null,
"customer": {
"card_id": "00000000-0000-4000-8000-000000000007"
},
"created_at": "2026-11-08T09:00:00.000Z",
"acknowledged_at": "2026-11-08T09:00:00.000Z",
"started_at": null,
"ready_at": null,
"served_at": null,
"rejected_at": "2026-11-08T09:00:00.000Z",
"updated_at": "2026-11-08T09:00:00.000Z"
}
}
}card.created
A customer gets a loyalty card at your business.
{
"id": "evt_00000000000040008000000000000037",
"type": "card.created",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": null,
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000038",
"card_number": "•••• 0000 0001",
"verified": false,
"joined_via": "poster",
"joined_at": "2026-11-08T09:00:00.000Z",
"balance": 0,
"visits": 0,
"program": {
"type": "stamps",
"stamps_per_reward": 7,
"max_stamps_per_visit": 5,
"points_per_sar": null,
"min_bill": null,
"max_points_per_visit": null,
"reward_name": {
"ar": "قهوة مجانية",
"en": "Free coffee"
}
},
"progress": {
"type": "stamps",
"stamps_to_next": 7,
"rewards_available": 0,
"points_to_next": null,
"next_reward_id": null,
"affordable_reward_ids": []
},
"rewards": []
}
}
}loyalty.earned
Stamps or points are added. data.event is the ledger entry; data.object is the card after it.
{
"id": "evt_00000000000040008000000000000009",
"type": "loyalty.earned",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000007",
"card_number": "•••• 0000 0001",
"verified": true,
"joined_via": "poster",
"joined_at": "2026-11-08T09:00:00.000Z",
"balance": 1,
"visits": 1,
"program": {
"type": "stamps",
"stamps_per_reward": 7,
"max_stamps_per_visit": 5,
"points_per_sar": null,
"min_bill": null,
"max_points_per_visit": null,
"reward_name": {
"ar": "قهوة مجانية",
"en": "Free coffee"
}
},
"progress": {
"type": "stamps",
"stamps_to_next": 6,
"rewards_available": 0,
"points_to_next": null,
"next_reward_id": null,
"affordable_reward_ids": []
},
"rewards": []
},
"event": {
"id": "00000000-0000-4000-8000-000000000011",
"card_id": "00000000-0000-4000-8000-000000000007",
"kind": "earn",
"source": "order",
"program_type": "stamps",
"amount": 1,
"bill_amount": null,
"reward": null,
"branch_id": "00000000-0000-4000-8000-000000000003",
"order_id": "00000000-0000-4000-8000-000000000004",
"api_key": null,
"reference": null,
"reason": null,
"voided": false,
"voided_at": null,
"created_at": "2026-11-08T09:00:00.000Z"
}
}
}loyalty.redeemed
A reward is redeemed. data.event is the ledger entry; data.object is the card after it.
{
"id": "evt_00000000000040008000000000000017",
"type": "loyalty.redeemed",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000007",
"card_number": "•••• 0000 0001",
"verified": true,
"joined_via": "poster",
"joined_at": "2026-11-08T09:00:00.000Z",
"balance": 1,
"visits": 2,
"program": {
"type": "stamps",
"stamps_per_reward": 7,
"max_stamps_per_visit": 5,
"points_per_sar": null,
"min_bill": null,
"max_points_per_visit": null,
"reward_name": {
"ar": "قهوة مجانية",
"en": "Free coffee"
}
},
"progress": {
"type": "stamps",
"stamps_to_next": 6,
"rewards_available": 0,
"points_to_next": null,
"next_reward_id": null,
"affordable_reward_ids": []
},
"rewards": []
},
"event": {
"id": "00000000-0000-4000-8000-000000000018",
"card_id": "00000000-0000-4000-8000-000000000007",
"kind": "redeem",
"source": "counter",
"program_type": "stamps",
"amount": -7,
"bill_amount": null,
"reward": null,
"branch_id": "00000000-0000-4000-8000-000000000003",
"order_id": null,
"api_key": null,
"reference": null,
"reason": null,
"voided": false,
"voided_at": null,
"created_at": "2026-11-08T09:00:00.000Z"
}
}
}loyalty.voided
A stamp, points, redemption or correction entry is undone. data.event is the entry that was undone.
{
"id": "evt_00000000000040008000000000000019",
"type": "loyalty.voided",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000007",
"card_number": "•••• 0000 0001",
"verified": true,
"joined_via": "poster",
"joined_at": "2026-11-08T09:00:00.000Z",
"balance": 8,
"visits": 2,
"program": {
"type": "stamps",
"stamps_per_reward": 7,
"max_stamps_per_visit": 5,
"points_per_sar": null,
"min_bill": null,
"max_points_per_visit": null,
"reward_name": {
"ar": "قهوة مجانية",
"en": "Free coffee"
}
},
"progress": {
"type": "stamps",
"stamps_to_next": 6,
"rewards_available": 1,
"points_to_next": null,
"next_reward_id": null,
"affordable_reward_ids": []
},
"rewards": []
},
"event": {
"id": "00000000-0000-4000-8000-000000000018",
"card_id": "00000000-0000-4000-8000-000000000007",
"kind": "redeem",
"source": "counter",
"program_type": "stamps",
"amount": -7,
"bill_amount": null,
"reward": null,
"branch_id": "00000000-0000-4000-8000-000000000003",
"order_id": null,
"api_key": null,
"reference": null,
"reason": null,
"voided": true,
"voided_at": "2026-11-08T09:00:00.000Z",
"created_at": "2026-11-08T09:00:00.000Z"
}
}
}loyalty.adjusted
The business corrected a card's stamps or points in the dashboard, or converted the balance when it changed its program. data.event.reason says why.
{
"id": "evt_00000000000040008000000000000020",
"type": "loyalty.adjusted",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": null,
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000007",
"card_number": "•••• 0000 0001",
"verified": true,
"joined_via": "poster",
"joined_at": "2026-11-08T09:00:00.000Z",
"balance": 9,
"visits": 2,
"program": {
"type": "stamps",
"stamps_per_reward": 7,
"max_stamps_per_visit": 5,
"points_per_sar": null,
"min_bill": null,
"max_points_per_visit": null,
"reward_name": {
"ar": "قهوة مجانية",
"en": "Free coffee"
}
},
"progress": {
"type": "stamps",
"stamps_to_next": 5,
"rewards_available": 1,
"points_to_next": null,
"next_reward_id": null,
"affordable_reward_ids": []
},
"rewards": []
},
"event": {
"id": "00000000-0000-4000-8000-000000000021",
"card_id": "00000000-0000-4000-8000-000000000007",
"kind": "adjust",
"source": "dashboard",
"program_type": "stamps",
"amount": 1,
"bill_amount": null,
"reward": null,
"branch_id": null,
"order_id": null,
"api_key": null,
"reference": null,
"reason": "Missed stamp",
"voided": false,
"voided_at": null,
"created_at": "2026-11-08T09:00:00.000Z"
}
}
}card.reward_ready
A card has just collected enough stamps or points for a reward (not when a program change converts the balance).
{
"id": "evt_00000000000040008000000000000016",
"type": "card.reward_ready",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000007",
"card_number": "•••• 0000 0001",
"verified": true,
"joined_via": "poster",
"joined_at": "2026-11-08T09:00:00.000Z",
"balance": 8,
"visits": 2,
"program": {
"type": "stamps",
"stamps_per_reward": 7,
"max_stamps_per_visit": 5,
"points_per_sar": null,
"min_bill": null,
"max_points_per_visit": null,
"reward_name": {
"ar": "قهوة مجانية",
"en": "Free coffee"
}
},
"progress": {
"type": "stamps",
"stamps_to_next": 6,
"rewards_available": 1,
"points_to_next": null,
"next_reward_id": null,
"affordable_reward_ids": []
},
"rewards": []
}
}
}menu.item_availability_changed
An item is marked sold out, or available again, at a branch. data.branch_id and data.sold_out say where and which; data.object.branches lists only that branch.
{
"id": "evt_00000000000040008000000000000022",
"type": "menu.item_availability_changed",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"id": "00000000-0000-4000-8000-000000000006",
"external_id": null,
"category_id": "00000000-0000-4000-8000-000000000026",
"name": {
"ar": "سبانش لاتيه",
"en": "Spanish latte"
},
"description": {
"ar": null,
"en": null
},
"photo_url": null,
"base_price": 0,
"calories": null,
"prep_minutes": null,
"allergens": [],
"tags": [],
"reward_eligible": false,
"earns_stamps_override": null,
"active": true,
"sort": 0,
"sizes": [
{
"id": "00000000-0000-4000-8000-000000000023",
"name": {
"ar": "صغير",
"en": "Small"
},
"price": 1700,
"is_default": false
},
{
"id": "00000000-0000-4000-8000-000000000024",
"name": {
"ar": "وسط",
"en": "Medium"
},
"price": 2100,
"is_default": true
},
{
"id": "00000000-0000-4000-8000-000000000025",
"name": {
"ar": "كبير",
"en": "Large"
},
"price": 2500,
"is_default": false
}
],
"option_groups": [
{
"id": "00000000-0000-4000-8000-000000000027",
"name": {
"ar": "الحليب",
"en": "Milk"
},
"required": true,
"min_select": 1,
"max_select": 1,
"options": [
{
"id": "00000000-0000-4000-8000-000000000028",
"name": {
"ar": "كامل الدسم",
"en": "Full fat"
},
"price_delta": 0
},
{
"id": "00000000-0000-4000-8000-000000000029",
"name": {
"ar": "شوفان",
"en": "Oat"
},
"price_delta": 300
}
]
},
{
"id": "00000000-0000-4000-8000-000000000030",
"name": {
"ar": "إضافات",
"en": "Extras"
},
"required": false,
"min_select": 0,
"max_select": 2,
"options": [
{
"id": "00000000-0000-4000-8000-000000000031",
"name": {
"ar": "شوت إضافي",
"en": "Extra shot"
},
"price_delta": 400
},
{
"id": "00000000-0000-4000-8000-000000000032",
"name": {
"ar": "فانيلا",
"en": "Vanilla"
},
"price_delta": 200
}
]
}
],
"branches": [
{
"branch_id": "00000000-0000-4000-8000-000000000003",
"sold_here": true,
"sold_out": true,
"stock": null,
"stock_updated_at": null
}
],
"updated_at": "2026-11-08T09:00:00.000Z"
},
"branch_id": "00000000-0000-4000-8000-000000000003",
"sold_out": true
}
}customer.data_deleted
A customer used Delete my data. data.object.card_id is their card at your business: delete what you hold about them.
{
"id": "evt_00000000000040008000000000000039",
"type": "customer.data_deleted",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": null,
"data": {
"object": {
"card_id": "00000000-0000-4000-8000-000000000040"
}
}
}stock.changed
ServeU's stock count for an item at a branch moved: an order took it off (reason order), a rejected, cancelled or expired order put it back (return; not for a reject with reject_reason item_unavailable, which puts nothing back), your system set it (api), or the owner stopped tracking it (untracked; current is null). data.object.previous and current are the counts before and after.
{
"id": "evt_00000000000040008000000000000033",
"type": "stock.changed",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"item_id": "00000000-0000-4000-8000-000000000034",
"external_id": null,
"branch_id": "00000000-0000-4000-8000-000000000003",
"previous": 1,
"current": 0,
"reason": "order",
"order_id": "00000000-0000-4000-8000-000000000035"
}
}
}stock.depleted
An item's stock at a branch reached 0 (sent right after the stock.changed that took it there). Customers now see it as sold out there.
{
"id": "evt_00000000000040008000000000000036",
"type": "stock.depleted",
"created_at": "2026-11-08T09:00:00.000Z",
"business_id": "00000000-0000-4000-8000-000000000002",
"branch_id": "00000000-0000-4000-8000-000000000003",
"data": {
"object": {
"item_id": "00000000-0000-4000-8000-000000000034",
"external_id": null,
"branch_id": "00000000-0000-4000-8000-000000000003",
"previous": 1,
"current": 0,
"reason": "order",
"order_id": "00000000-0000-4000-8000-000000000035"
}
}
}