← Developers

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.bookedAn 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.confirmedA 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.cancelledAn 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.movedAn 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-EventThe event name, also in the body.
X-Torim-DeliveryA uuid, stable across every retry of the same delivery. Key your idempotency on this.
X-Torim-Signaturet=<unix seconds>,v1=<hex hmac>
User-AgentTorim-Webhooks/1
Content-Typeapplication/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 t as 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:

1immediately
21 minute
35 minutes
415 minutes
51 hour
63 hours
76 hours
812 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_urlNot a URL, not HTTPS, or carrying credentials.
unsafe_urlPoints inside a private network.
webhook_limitA 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.