Developers

Bookora Clinic API

On this page (9 sections)

Use the Bookora API to connect a clinic’s website, app or software to its appointments and basic patient details. Requests run server to server, using a key created by the clinic owner.

نسخهٔ فارسی

The API is part of the کلینیک plan (and its trial). If the clinic moves to a plan without it, every call is refused with FEATURE_NOT_IN_PLAN; the panel and exports keep working.

Quick start

  1. The clinic owner creates a key with the scopes it needs under Settings › Integrations (API) in the panel. The key is shown once.
  2. Keep the key on your server only — never in website or mobile-app code.
  3. Your first call:
curl https://bookora.net/api/v1/clinic-api/me \
  -H "Authorization: Bearer bk_live_<public-id>_<secret>"

Base URL: https://bookora.net/api/v1/clinic-api. Responses are {"data": …, "meta": …}; errors are {"statusCode", "code", "message", "details", "requestId"} with a Persian message. Money is integer rials; instants are UTC ISO-8601.

Authentication

Every request must include Authorization: Bearer bk_live_<public-id>_<secret> . A revoked or expired key is refused at once, and a key can be rotated with a grace period for the old secret.

Scopes

Scope Grants Requires
catalog.read Services, prices and doctors —
availability.read Availability —
appointments.read Read appointments —
appointments.write Book, cancel and reschedule appointments.readavailability.read
patients.read Find and read basic patient details —
patients.write Create patients patients.read

Nobody can create a key with more access than they hold in the panel.

Rate limits

  • Per key: 120 requests a minute
  • Per clinic, all keys together: 300 requests a minute
  • Writes (book, cancel, reschedule, create patient): 30 a minute per key
  • Patient lookups: 30 a minute per key

Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset . Past the limit the answer is 429 with Retry-After.

Idempotency

Booking and creating a patient require an Idempotency-Key header (8–128 characters); cancel and reschedule accept one. The same key with the same body returns the same result for 24 hours (with Idempotent-Replayed: true) and never creates a duplicate.

curl -X POST https://bookora.net/api/v1/clinic-api/appointments \
  -H "Authorization: Bearer bk_live_<public-id>_<secret>" \
  -H "Idempotency-Key: 6f1c2a9e-booking-4411" \
  -H "Content-Type: application/json" \
  -d '{"patientId":"<patient-id>","serviceId":"<service-id>","startAt":"2026-10-10T07:30:00.000Z"}'

Endpoints

GET /me

The key, its clinic and its rate limits

Scope: none

GET /services

Active services with duration and price (rials)

Scope: catalog.read

GET /doctors

Active doctors

Scope: catalog.read

GET /availability

Free slots, up to two weeks per request

Scope: availability.read

Name In Required Notes
from query yes Range start (ISO-8601)
to query yes Range end (ISO-8601)
serviceId query yes Service id
doctorId query no Only this doctor
branchId query no Only this branch

GET /appointments

Appointments; no notes, no amounts

Scope: appointments.read

Name In Required Notes
from query no From
to query no To
status query no Appointment status
doctorId query no Doctor
patientId query no Patient
branchId query no Branch
updatedSince query no Changes since (deleted rows come back with `deleted`)
page query no Page number
pageSize query no Page size, at most 100

GET /appointments/{id}

One appointment

Scope: appointments.read

Name In Required Notes
id path yes Appointment id

POST /appointments

Book a free slot; CONFIRMED by default

Scope: appointments.write · Idempotency-Key: required

Name In Required Notes
patientId body yes Patient id
serviceId body yes Service id
startAt body yes Start time (ISO-8601)
doctorId body no Preferred doctor
branchId body no Branch
status body no CONFIRMED or REQUESTED
notes body no Note, at most 500 characters

POST /appointments/{id}/cancel

Cancel an appointment

Scope: appointments.write · Idempotency-Key: optional

Name In Required Notes
id path yes Appointment id
reason body no Reason, at most 300 characters

POST /appointments/{id}/reschedule

Move an appointment to another free slot

Scope: appointments.write · Idempotency-Key: optional

Name In Required Notes
id path yes Appointment id
startAt body yes New start time (ISO-8601)
doctorId body no Preferred doctor

GET /patients/lookup

Find a patient by exact mobile number or national id

Scope: patients.read

Name In Required Notes
phone query no Mobile number (one of the two)
nationalId query no National id (one of the two)

GET /patients/{id}

Basic patient details; never medical history

Scope: patients.read

Name In Required Notes
id path yes Patient id

POST /patients

Create a patient

Scope: patients.write · Idempotency-Key: required

Name In Required Notes
firstName body yes First name
lastName body yes Last name
phone body yes Mobile number
gender body no Gender
birthDate body no Date of birth
nationalId body no National id
acquisitionSource body no Acquisition source

Errors

Status code Meaning
400 VALIDATION_FAILED Invalid input; `details` names the field
400 IDEMPOTENCY_KEY_REQUIRED The Idempotency-Key header is required
400 APPOINTMENT_NOT_CANCELLABLE A finished appointment cannot be cancelled
401 API_KEY_REQUIRED No key sent
401 API_KEY_INVALID The key is not valid
401 API_KEY_REVOKED The key was revoked
401 API_KEY_EXPIRED The key has expired
402 FEATURE_NOT_IN_PLAN The clinic’s plan has no API access, or its subscription has ended; every call is refused
403 API_SCOPE_DENIED The key lacks this scope
404 *_NOT_FOUND No such id in this clinic
409 APPOINTMENT_SLOT_TAKEN The slot is no longer free
409 IDEMPOTENCY_IN_PROGRESS The same request is still running
422 IDEMPOTENCY_KEY_REUSED The same key with a different body
429 RATE_LIMITED Rate limited; honour Retry-After
503 CLINIC_API_DISABLED The API is temporarily switched off

Webhooks

Pick the events for each URL in the panel. The URL must be HTTPS and reachable from inside Iran; private-network addresses are refused. Webhook payloads carry no patient identity.

  • appointment.created
  • appointment.confirmed
  • appointment.rescheduled
  • appointment.cancelled
  • appointment.no_show
  • appointment_request.created
{
  "id": "evt_…",
  "type": "appointment.cancelled",
  "apiVersion": "2026-10-01",
  "createdAt": "2026-10-10T07:31:02.000Z",
  "clinicId": "…",
  "data": {
    "appointment": {
      "id": "…", "status": "CANCELLED",
      "startAt": "…", "endAt": "…",
      "patientId": "…", "doctorId": "…",
      "serviceIds": ["…"], "source": "API"
    },
    "cancelledByPatient": false,
    "actor": { "type": "api_key", "apiKeyId": "…" }
  }
}

Signature

Each delivery includes X-Bookora-Signature: t=<unix>,v1=<hex> : an HMAC-SHA256 with the webhook secret over “t”, a dot and the raw body. While a secret is rotating, a second v1 signed with the previous secret is included. X-Bookora-Event and X-Bookora-Delivery are sent too.

Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the request body exactly as received (a Buffer or string).
export function verifyBookoraSignature(rawBody, header, secret, toleranceSec = 300) {
  const items = header.split(',').map((part) => part.trim().split('='));
  const t = items.find(([key]) => key === 't')?.[1];
  const signatures = items.filter(([key]) => key === 'v1').map(([, value]) => value);
  if (!t || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return signatures.some(
    (sig) => sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
  );
}

PHP

function verify_bookora_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $t = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't') { $t = $value; }
        if ($key === 'v1') { $signatures[] = $value; }
    }
    if ($t === null || !$signatures || abs(time() - (int) $t) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) { return true; }
    }
    return false;
}

Python

import hashlib
import hmac
import time


def verify_bookora_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    t, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            signatures.append(value)
    if t is None or not signatures or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)

Retries and auto-disable

A 2xx response confirms receipt. Any other response, a redirect, or no response within 10 seconds counts as a failed delivery. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours — about two days in all. A URL that fails 50 times in a row, or has no success for 72 hours with at least 10 failures, is disabled automatically, and the clinic owner is notified.

Delivery order is not guaranteed. Sort events by createdAt and fetch the appointment again to get its current state.

Changelog

apiVersion: 2026-10-01 — first release: twelve appointment and patient routes, six webhook events, and branches on appointments and availability.