Before you start
Who this is for, and the one rule that matters.
This is a REST API over one business's own data: its bookings, its customers, its catalogue, its orders, its missed calls and — for restaurants — its tables. You call it from a server with a key the business owner creates for you. It speaks JSON over HTTPS and needs no SDK.
The owner creates the key in their dashboard under API keys, chooses exactly what it may reach, and hands you the value once. Nobody can show it to you again, including us — if it is lost, they rotate it and you get a new one.
Ask the owner for a key with the permissions you actually need and no more. A kitchen display that only lists tonight's bookings should hold one read permission, so a screen left in a public room is not a way into the customer list.
Base URL and authentication
One header, one shape, one answer for every failure.
https://vaem.ai/v1Send the key as a bearer token on every request. A key looks like vaem_sk_live_<id>_<secret>, or vaem_sk_test_… in test mode.
curl https://vaem.ai/v1/ping \
-H "Authorization: Bearer vaem_sk_live_7b6b6d7c979a2b11_XuY4…"{
"ok": true,
"env": "live",
"request_id": "req_238d4bf07f5149eca62f63291a6419e0"
}Every failure to authenticate answers the same 401, whether the key is unknown, wrong, revoked, expired, paused or belongs to a closed account. That is deliberate: a more helpful message would also help whoever is guessing.
- The key goes in the Authorization header. A key in a query string is refused outright — query strings land in access logs, browser history and the Referer header of any outbound link.
- There are no CORS headers on this API, so a browser cannot call it at all.
- The owner can lock a key to specific server addresses or ranges. If they have, a request from anywhere else is the same 401. Give them the public address of the machine that will call us.
- Every response carries X-Request-Id, and every error repeats it in the body. Quote that value in a support message and we can find the exact call.
| Permission | Lets a key |
|---|---|
| bookings:read | List and read bookings, and check availability |
| bookings:write | Create and change bookings |
| bookings:cancel | Cancel a booking (separate on purpose) |
| customers:read | List and read customers |
| customers:write | Create and update customers, and change their segments |
| catalogue:read | Read the catalogue |
| catalogue:write | Create, change and delete catalogue items |
| orders:read | Read orders and payments |
| missedcalls:read | Read missed calls |
| missedcalls:write | Mark a missed call handled |
| tables:read | Read the floor plan and occupancy (restaurants) |
Test mode
Real records, no consequences.
A key beginning vaem_sk_test_ validates everything a live key does — the schema, ownership, the plan's limits, the diary — and then writes nothing. You get the object as it would have been, with test_mode: true and an id prefixed test_.
- Phone numbers and email addresses come back masked, so a test key in a build log is not a customer list.
- Nothing is created, changed or deleted. A test delete deletes nothing.
- Availability is still checked honestly, so "would this have worked?" gets a real answer.
- Test traffic is rate limited but never counted against the business's monthly allowance.
{
"id": "test_9f2c1b7e4a03d5610cbb84f2",
"name": "Priya Sharma",
"phone": "+•••••••••123",
"email": "p•••@example.com",
"test_mode": true
}Errors
One shape, a stable code, and the id that is also in our logs.
{
"error": {
"type": "invalid_request_error",
"code": "slot_unavailable",
"message": "We are fully booked at 19:30.",
"param": "time",
"request_id": "req_238d4bf07f5149eca62f63291a6419e0"
}
}Branch on code, never on message. The code is part of the contract; the message is written for a human and may be reworded. param names the field at fault when one field is at fault.
| Status | code | What to do |
|---|---|---|
| 400 | invalid_parameter | Fix the field named in param. |
| 400 | invalid_json | The body was not valid JSON. |
| 401 | invalid_api_key | The key is unknown, wrong, revoked, expired, paused, or used from a disallowed address. |
| 403 | insufficient_scope | Ask the owner for the permission named in the message. |
| 403 | browser_request_refused | The request carried an Origin header. Move the key to a server. |
| 403 | test_key_not_permitted | This action needs a live key. |
| 403 | api_disabled_for_account | API access is paused on this account. The message says why. |
| 402 | plan_limit_reached | The business is at a plan limit. Waiting will not help. |
| 402 | monthly_quota_exceeded | This month's changes are spent. Resets on the 1st. |
| 404 | not_found | No such record on this account. |
| 409 | slot_unavailable | That time is taken or outside the business's rules. |
| 409 | item_in_use | The catalogue item appears in a past order. Deactivate it instead. |
| 413 | request_too_large | Bodies are capped at 100 KB. |
| 422 | idempotency_key_reuse | Same Idempotency-Key, different body. |
| 429 | rate_limited | Slow down. Retry-After says how long. |
| 503 | service_busy | We are busy. Retry in a few seconds. |
| 503 | booking_busy | Another booking held the day's lock. Retry once. |
| 500 | internal_error | Our fault. Retry, then quote the request_id. |
Rate limits and monthly changes
Two different ceilings, because they stop two different problems.
A rate limit stops a burst. A monthly allowance stops a client that sits politely inside the rate limit for thirty days. Reads never count against the monthly allowance — only calls that change something do.
| Plan | Requests a minute, per key | Per account | Changes a month |
|---|---|---|---|
| Starter | 60 | 120 | 10,000 |
| Growth | 180 | 360 | 50,000 |
| Pro | 600 | 1,200 | 250,000 |
| Enterprise | Custom | Custom | Custom |
The per-account number is the one that actually binds: a business with five keys does not get five times its plan. Every response publishes the tighter of the two so you can back off against the limit you will really hit.
RateLimit-Limit: 600
RateLimit-Remaining: 583
RateLimit-Reset: 37
X-Quota-Limit: 250000
X-Quota-Remaining: 249318- A 429 carries Retry-After in seconds. Honour it; retrying sooner just spends the next window.
- Spread bulk work out rather than firing it in one burst — the limit is a sliding window, so a burst at the end of one minute still counts at the start of the next.
- When the monthly allowance is spent you get 402 monthly_quota_exceeded. Waiting does not fix that one; the business needs a bigger plan or the next month.
Safe retries
A dropped connection must not produce two bookings.
Send an Idempotency-Key header on anything that changes something. It is required on creating a booking — the one place a retry costs a real table twice — and accepted everywhere else. Use a fresh value per logical operation: a UUID is ideal.
curl https://vaem.ai/v1/bookings \
-H "Authorization: Bearer vaem_sk_live_…" \
-H "Idempotency-Key: 8f21c0de-5b2a-4f0e-9a6e-1d3c7b90f4aa" \
-H "Content-Type: application/json" \
-d '{ "customer_id": "c1f3…", "date": "2026-10-02", "time": "19:30" }'- The first call runs. A repeat within 24 hours returns the identical response with Idempotent-Replay: true, without running anything again.
- The same key with a different body is 422 idempotency_key_reuse — that is a bug in your code, and a silent overwrite would hide it.
- A repeat while the first is still running is 409 idempotency_in_progress. Wait a moment and retry.
- If our side returned a 5xx, the key is released so an honest retry is allowed to actually run.
- Keys are scoped to your account and to the endpoint, so the same value on a different endpoint is a new request, not a replay.
Pagination
Cursors, capped, no total.
List endpoints return newest first. Pass ?limit= between 1 and 100 (25 by default) and follow next_cursor until it is null. There is no page number and no total count: counting a busy account's rows on every page would be a performance problem for the business paying us.
{
"data": [ { "id": "…" }, { "id": "…" } ],
"has_more": true,
"next_cursor": "MjAyNi0xMC0wMlQxODozMDowMC4wMDBafDljMWYz"
}curl "https://vaem.ai/v1/customers?limit=100&cursor=MjAyNi0xMC0wMlQxODozMDowMC4wMDBafDljMWYz" \
-H "Authorization: Bearer vaem_sk_live_…"Treat the cursor as opaque. Its contents may change; a cursor you did not get from us is 400 invalid_parameter.
Bookings
The diary: read it, check it, fill it, move it, cancel it.
- GET
/v1/bookingsbookings:readFilters: status, date_from, date_to, branch_id, customer_id - GET
/v1/bookings/availabilitybookings:readdate, time, optional party_size and branch_id - GET
/v1/bookings/{id}bookings:readBy id or by the reference the customer was given - POST
/v1/bookingsbookings:writeIdempotency-Key required - PATCH
/v1/bookings/{id}bookings:writeMove it, resize it, change its status - POST
/v1/bookings/{id}/cancelbookings:cancelIts own permission, deliberately
Check first, then book. Availability answers with the business's own rules applied — opening hours, notice period, capacity, and table allocation for restaurants — so a slot it accepts is a slot the dashboard would have accepted.
curl "https://vaem.ai/v1/bookings/availability?date=2026-10-02&time=19:30&party_size=4" \
-H "Authorization: Bearer vaem_sk_live_…"{
"available": false,
"reason": "We are fully booked at that time.",
"opening_hours": { "open": "12:00", "close": "23:00" },
"alternative_times": ["18:45", "21:15"],
"table_ids": []
}curl https://vaem.ai/v1/bookings \
-H "Authorization: Bearer vaem_sk_live_…" \
-H "Idempotency-Key: 8f21c0de-5b2a-4f0e-9a6e-1d3c7b90f4aa" \
-H "Content-Type: application/json" \
-d '{
"customer": { "name": "Priya Sharma", "phone": "+447700900123" },
"date": "2026-10-02",
"time": "21:15",
"guest_count": 4,
"special_requests": "Window table if possible"
}'{
"id": "9c1f3a70-4d2e-4f88-b0c1-6a2d5e7f1b34",
"reference": "BK-3F9A2C81B4",
"date": "2026-10-02",
"time": "21:15",
"status": "pending",
"guest_count": 4,
"service": null,
"customer_name": "Priya Sharma",
"special_requests": "Window table if possible",
"amount": null,
"currency": "GBP",
"payment_status": "not_required",
"deposit_paid": false,
"branch_id": null,
"table_ids": ["t_12"],
"customer_id": "c1f3b902-7e55-4a10-9d3f-2b8c4e6a1097",
"created_at": "2026-09-14T10:22:41.310Z",
"updated_at": "2026-09-14T10:22:41.310Z"
}- Identify the customer with customer_id, or send a customer object to find or create one — that second form needs customers:write as well, because it can create a person.
- status comes back pending when the business approves bookings by hand and confirmed when it does not. Do not assume either.
- A 200 instead of a 201 means the duplicate guard recognised this exact booking and handed back the existing one.
- Moving a booking re-checks the diary, so a PATCH can answer 409 slot_unavailable just as a create can.
- Cancelling something already cancelled is not an error — a retry after a dropped connection should be able to finish.
Customers
The contact list, and the segments built on it.
- GET
/v1/customerscustomers:readFilters: search (name, phone, email), tag - GET
/v1/customers/{id}customers:read - POST
/v1/customerscustomers:write200 = matched an existing person, 201 = created - PATCH
/v1/customers/{id}customers:writeName, email and custom fields - POST
/v1/customers/{id}/tagscustomers:writeAdd to one or more segments - DELETE
/v1/customers/{id}/tags/{name}customers:writeRemove from a segment
Creating a customer matches on the phone number ignoring the country code, so importing a list does not produce a second copy of everyone the business already knows. You can tell which happened from the status: 201 created, 200 matched.
curl https://vaem.ai/v1/customers \
-H "Authorization: Bearer vaem_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Priya Sharma",
"phone": "+447700900123",
"tags": ["Website"],
"custom_fields": { "membership": "gold", "since": 2024 }
}'{
"id": "c1f3b902-7e55-4a10-9d3f-2b8c4e6a1097",
"name": "Priya Sharma",
"phone": "+447700900123",
"email": null,
"tags": ["Website"],
"custom_fields": { "membership": "gold", "since": 2024 },
"total_bookings": 0,
"total_orders": 0,
"total_spent": 0,
"last_visit_at": null,
"marketing_opted_in": true,
"blocked": false,
"created_at": "2026-09-14T10:19:02.441Z",
"updated_at": "2026-09-14T10:19:02.441Z"
}- custom_fields is a flat object: up to 50 keys, each a string, number, boolean or null.
- The phone number cannot be changed through the API. It is the identity WhatsApp threads conversations by, and rewriting it would silently move a chat history onto a different person.
- Consent flags cannot be set through the API either. Opting somebody back in by API call is exactly the move that gets a WhatsApp account restricted.
- There is no delete. Removing a customer takes their bookings, conversations and payment records with them, so that stays a dashboard action with a human reading the warning.
Catalogue
Whatever the business sells: dishes, services, treatments, parts.
- GET
/v1/cataloguecatalogue:readFilters: category, search, in_stock, include_inactive - GET
/v1/catalogue/{id}catalogue:read - POST
/v1/cataloguecatalogue:write - PATCH
/v1/catalogue/{id}catalogue:write - DELETE
/v1/catalogue/{id}catalogue:write409 if the item appears in a past order
The path says catalogue because the word a business sees is theirs to choose — menu, services, treatments, products — and a URL cannot change under a running integration.
curl https://vaem.ai/v1/catalogue \
-H "Authorization: Bearer vaem_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Lamb rogan josh",
"description": "Slow-cooked shoulder, Kashmiri chillies",
"price": 14.5,
"category": "Mains",
"sku": "MN-014",
"image_url": "https://cdn.example.com/rogan-josh.jpg",
"in_stock": true
}'- Inactive items are hidden unless you ask for them with include_inactive=true, so a public menu renders correctly without knowing a filter exists.
- A price of 0 comes back as null. Zero means "ask us", not free, and the API will not quietly turn one into the other.
- Image addresses must be http or https. Up to five images per item.
- Deleting an item that appears in a past order is refused, because the order would lose what was bought. Set active to false instead.
Orders and payments
Read only, permanently.
- GET
/v1/ordersorders:readFilters: status, customer_id - GET
/v1/orders/{id}orders:read
There is no write half here and there will not be one. A key that could take, refund or settle a payment is a key that can move money, and no integration is worth that. Money changes through the payment provider and through the dashboard, both of which have a human and a verified webhook behind them.
{
"id": "o_4b17…",
"reference": "ORD-88213",
"description": "Table 12 — dinner",
"source_type": "booking",
"source_id": "9c1f3a70-…",
"amount": 86.4,
"currency": "GBP",
"platform_fee": 1.3,
"net_amount": 85.1,
"refunded_amount": 0,
"status": "paid",
"payout_status": "pending",
"paid_at": "2026-09-13T21:04:55.000Z",
"customer_id": "c1f3b902-…",
"created_at": "2026-09-13T20:41:02.118Z",
"updated_at": "2026-09-13T21:04:55.402Z"
}Missed calls
Every call the business did not get to.
- GET
/v1/missed-callsmissedcalls:readFilters: handled, follow_up_status - GET
/v1/missed-calls/{id}missedcalls:read - POST
/v1/missed-calls/{id}/handledmissedcalls:writeMark dealt with, or put it back
Nothing creates or deletes a missed call through the API: it is a record of something that happened, and an integration that could invent calls could invent evidence. Marking one handled twice is not an error, so a retry can finish.
curl https://vaem.ai/v1/missed-calls/mc_7d21…/handled \
-H "Authorization: Bearer vaem_sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "handled": true, "by": "Front desk" }'Tables
The floor plan, for restaurants.
- GET
/v1/tablestables:readThe plan. Filters: branch_id, include_inactive - GET
/v1/tables/occupancytables:readdate, optional time — who is sitting where - GET
/v1/tables/{id}tables:read
This permission is only available to a restaurant account. Tables come back with their coordinates so you can draw your own plan rather than being locked into ours, and occupancy names the booking holding each one so you can label a table without a call per table.
{
"date": "2026-10-02",
"time": "20:00",
"data": [
{
"id": "t_12",
"name": "12",
"seats": 4,
"zone": "Window",
"occupied": true,
"position": { "x": 220, "y": 80, "width": 90, "height": 90 },
"held_by": {
"booking_id": "9c1f3a70-…",
"reference": "BK-3F9A2C81B4",
"time": "19:30",
"guest_count": 4
}
}
],
"has_more": false,
"next_cursor": null
}Webhooks
We tell your server when something happens, instead of you asking.
Give us an https address and we post to it when something changes. Every delivery is signed, retried on a schedule if your server is down, and listed in a log you can read back. Polling still works and always will — webhooks are the cheaper way to stay current.
| Event | Fires when |
|---|---|
| booking.created | A booking is made, from any channel |
| booking.updated | A booking is moved or its status changes |
| booking.cancelled | A booking is cancelled |
| customer.created | A person the business has not seen before appears |
| order.paid | A payment settles |
| order.refunded | A payment is refunded |
| missed_call.received | A call to the business is missed |
- GET
/v1/webhookswebhooks:manageYour endpoints, plus the event catalogue - POST
/v1/webhookswebhooks:manageReturns the signing secret once - GET
/v1/webhooks/{id}webhooks:manage - PATCH
/v1/webhooks/{id}webhooks:manageAddress, events, or switch it off - DELETE
/v1/webhooks/{id}webhooks:manageWe stop sending immediately - POST
/v1/webhooks/{id}/rotate-secretwebhooks:manageNew secret, old one valid for the overlap - POST
/v1/webhooks/{id}/testwebhooks:manageA real, signed delivery - GET
/v1/webhooks/deliverieswebhooks:manageWhat we sent and how it went - POST
/v1/webhooks/deliveries/{id}/retrywebhooks:manageSend one again now
curl https://vaem.ai/v1/webhooks \
-H "Authorization: Bearer vaem_sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourcompany.com/hooks/vaem",
"events": ["booking.created", "booking.cancelled"],
"description": "Sync bookings into our calendar"
}'{
"id": "we_7c21…",
"url": "https://api.yourcompany.com/hooks/vaem",
"events": ["booking.created", "booking.cancelled"],
"env": "live",
"enabled": true,
"secret_hint": "vaem_whsec_…W0Xw",
"secret": "vaem_whsec_Q8ry…",
"created_at": "2026-09-14T11:02:13.884Z"
}- The address must be https on port 443. Anything pointing at a private, loopback or cloud-metadata address is refused, at setup and again before every delivery.
- Leave events out to receive everything, including event types we add later.
- A key created in test mode makes test endpoints, which only ever receive test deliveries. A live endpoint never receives a test one.
- The secret is in the create response and nowhere else. Rotate to get a new one.
POST /hooks/vaem HTTP/1.1
Content-Type: application/json
X-Vaem-Event: booking.created
X-Vaem-Delivery-Id: 0f5a1c9e-0d7b-4a2c-9e11-53b0a7c8d412
X-Vaem-Timestamp: 1757664000
X-Vaem-Signature: t=1757664000,v1=10c6e2c00313…
{
"id": "ev_4b7d…",
"type": "booking.created",
"created_at": "2026-09-14T11:02:13.884Z",
"data": {
"id": "9c1f3a70-4d2e-4f88-b0c1-6a2d5e7f1b34",
"reference": "BK-3F9A2C81B4",
"date": "2026-10-02",
"time": "21:15",
"status": "pending",
"guest_count": 4,
"branch_id": null,
"customer_id": "c1f3b902-7e55-4a10-9d3f-2b8c4e6a1097"
}
}const crypto = require("crypto");
// The RAW body, before any JSON parsing. Re-serialising changes the bytes and
// the signature will never match.
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=").map((s) => s.trim())),
);
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // five minutes
const expected = crypto
.createHmac("sha256", secret)
.update(t + "." + rawBody)
.digest();
// A rotation sends two v1= values; either one matching is a valid delivery.
return header
.split(",")
.filter((p) => p.trim().startsWith("v1="))
.some((p) => {
const given = Buffer.from(p.trim().slice(3), "hex");
return (
given.length === expected.length &&
crypto.timingSafeEqual(given, expected)
);
});
}<?php
function vaem_verify(string $rawBody, string $header, string $secret): bool {
$t = 0; $sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_map('trim', explode('=', $part, 2));
if ($k === 't') $t = (int) $v;
if ($k === 'v1') $sigs[] = $v;
}
if (!$t || abs(time() - $t) > 300) return false; // five minutes
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) return true;
}
return false;
}- Answer 2xx as soon as you have stored the delivery. Do the slow work afterwards — we time out after eight seconds and treat that as a failure.
- Delivery is at least once. If your 200 is lost on the way back to us, you will get the same delivery again, so dedupe on X-Vaem-Delivery-Id.
- Redirects are never followed. A 3xx counts as a failed attempt, so give us the final address.
- The payload is deliberately thin — ids and status, never a phone number or an email. Fetch the detail with your key, where your permissions apply.
If a delivery fails we retry five times, roughly after one minute, five minutes, half an hour, two hours and eight hours, with a little jitter. After that the delivery is given up on and recorded as failed. Ten failed deliveries in a row and we switch the endpoint off and email the owners — nothing is lost, and switching it back on in the dashboard resumes from the next event.
curl -X POST https://vaem.ai/v1/webhooks/we_7c21…/rotate-secret \
-H "Authorization: Bearer vaem_sk_live_…" \
-H "Content-Type: application/json" \
-d '{ "overlap_hours": 24 }'During the overlap every delivery carries both signatures, so you can deploy the new secret whenever suits you. Send overlap_hours: 0 to kill the old secret immediately — the right answer if it has leaked.
Changes and versions
What we may change under you, and what we may not.
Everything lives under /v1. Adding an optional field to a response, adding an endpoint, or adding a new value to an existing list is not a breaking change and can happen at any time. Build so that an unexpected field is ignored rather than fatal.
- Removing a field, renaming one, changing its type, or making an optional input required would be a breaking change, and would ship as /v2 rather than under you.
- Error codes are part of the contract. Messages are not — branch on code.
- Ids are opaque strings. Do not parse them, and do not assume a length or a format.
- Money is a number in the currency named alongside it. Do not assume the business uses pounds.
- Timestamps are ISO 8601 in UTC. Dates that people think of as days — a booking's date — are plain YYYY-MM-DD.
When something is wrong
- Every response carries X-Request-Id and every error repeats it as request_id. Send us that and we can find the exact call, including what it reached and why it failed.
- A 401 that started suddenly usually means the key was rotated, revoked, expired, or the account locked it to a different server address. The owner can see all four in their dashboard under API keys.
- A 403 naming a permission means the key was created without it. The owner can create a replacement with the right permissions in about a minute.
- If calls to the whole API answer 503 api_paused, that is us, not you. It is a deliberate pause and it will say so.
Something here wrong or missing? Tell us and quote a request id if you have one.