Webhooks v1
Rather than polling, have Torim tell you. A manager adds a URL under Integrations, and every change to that business's diary is POSTed to it, signed.
See also the HTTP API and the MCP server.
Events
Four, and this is all of them. An endpoint subscribes to any combination; sending no list when you add the URL subscribes it to everything.
The same event fires whichever surface caused the change — what is announced is what happened, not who did it.
| appointment.booked | An appointment is created. A customer booking on the site, the front desk entering a walk-in, an API key, or an assistant. Where the business approves bookings, a request arrives as this event with status "pending", and its answer follows as appointment.confirmed or appointment.cancelled. |
| appointment.confirmed | A booking request is approved. The business, from the diary, approving a request that arrived as appointment.booked with status "pending". The body is the same appointment, now with status "confirmed". A booking that never needed approval does not send this — it was confirmed when it was booked. An endpoint added before this event existed and subscribed to appointment.booked was subscribed to it too. |
| appointment.cancelled | An appointment is called off. The customer from their own page, or the business from the diary — declining a request included, and a request nobody answered before it started. Marking somebody a no-show is not this — nothing left the calendar. |
| appointment.moved | An appointment changes time. Either side. One event, not a cancellation followed by a booking, so a receiver can keep the same record. |
A visit of several services booked back to back is one appointment per part, and each part
is its own event: booking, approving, moving or cancelling a visit of three sends three,
each carrying the same visit_id.
There is also ping, which only the "send a test" button
produces. Nothing can subscribe to it, so it can never arrive unasked.
The body
An envelope naming the event, and the appointment itself under data — the same shape GET /api/v1/appointments/{id} returns, so one type reads
both.
{
"event": "appointment.booked",
"created_at": "2026-09-07T09:12:44.981Z",
"data": {
"id": "5f1c…",
"reference": "5F1C7A20",
"status": "confirmed",
"starts_at": "2026-09-14T07:00:00Z",
"ends_at": "2026-09-14T08:00:00Z",
"service": "deep-tissue",
"service_name": { "he": "…", "en": "Deep tissue massage" },
"staff_id": "aaaa…",
"staff_name": "…",
"customer_name": "…",
"customer_phone": "050-000-0000",
"price_agorot": 30000,
"created_at": "2026-09-07T09:12:44Z"
}
} status is one of the values listed in the API reference. Absent fields are
omitted rather than sent as null — a walk-in with no email has no customer_email key. The payload is built when the event happens
and stored, so a retry an hour later sends what was true then, not what is true now.
Headers
| X-Torim-Event | The event name, also in the body. |
| X-Torim-Delivery | A uuid, stable across every retry of the same delivery. Key your idempotency on this. |
| X-Torim-Signature | t=<unix seconds>,v1=<hex hmac> |
| User-Agent | Torim-Webhooks/1 |
| Content-Type | application/json |
A delivery can arrive more than once — a receiver that answered slowly still gets retried
— so treat X-Torim-Delivery as the key and make handling it idempotent.
Verifying a delivery
v1 is HMAC-SHA256 over {t}.{raw body}, keyed by the signing secret shown once
when the URL was added. Two things matter and are easy to get wrong:
- — Sign the body exactly as received. Parsing and re-serialising it changes the bytes and the signature with them.
- — Check
tas well, and reject anything older than a few minutes. The timestamp is inside the signed string precisely so a captured delivery cannot be replayed at you later.
Python
import hmac, hashlib, time
def valid(secret: str, header: str, body: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > tolerance:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.{body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts["v1"]) Node
import { createHmac, timingSafeEqual } from 'node:crypto';
export function valid(secret, header, body, tolerance = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = createHmac('sha256', secret).update(`${parts.t}.${body}`).digest('hex');
return (
expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
);
} Compare in constant time, as both of these do. A plain string comparison leaks how much of a forged signature was right.
Retries and failure
Any 2xx counts as delivered. Anything else — a 500, a timeout, a refused connection — is retried on this schedule:
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 15 minutes |
| 5 | 1 hour |
| 6 | 3 hours |
| 7 | 6 hours |
| 8 | 12 hours |
| — | then given up on, about two days after the event |
Answer quickly and do the work afterwards: a receiver has ten seconds before the attempt is abandoned. Redirects are not followed and the response body is never read, so there is nothing to gain by returning one.
An endpoint that fails twenty times in a row is switched off — that is a receiver that has gone away rather than one having a bad afternoon. Rotating its secret switches it back on, which is also how you say "I have fixed it".
What a URL may be
HTTPS, with no username or password in it, resolving to a public address. Private, loopback, link-local and carrier-grade-NAT addresses are refused — including a name that resolves to one, which is checked again at every connection rather than only when the URL is saved.
| invalid_url | Not a URL, not HTTPS, or carrying credentials. |
| unsafe_url | Points inside a private network. |
| webhook_limit | A business may point at five URLs. |
Testing yours
The Integrations screen has a "send a test" button, which queues a ping through the same worker a real event uses — signed the same
way, retried the same way. Beside it is the delivery history: what was sent, what came back,
and when the next attempt is due.
Deliveries are queued the moment the diary changes and sent within about ten seconds. If nothing arrives, the history says whether Torim could not reach you or you answered with something other than a 2xx.
Something not answered here? Get in touch.