GET /me
The key, its clinic and its rate limits
Developers
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.
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.
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.
| 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.
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset . Past the limit the answer is 429 with Retry-After.
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"}' GET /meThe key, its clinic and its rate limits
GET /servicesActive services with duration and price (rials)
GET /doctorsActive doctors
GET /availabilityFree slots, up to two weeks per request
| 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 /appointmentsAppointments; no notes, no amounts
| 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
| Name | In | Required | Notes |
|---|---|---|---|
id | path | yes | Appointment id |
POST /appointmentsBook a free slot; CONFIRMED by default
| 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}/cancelCancel an appointment
| Name | In | Required | Notes |
|---|---|---|---|
id | path | yes | Appointment id |
reason | body | no | Reason, at most 300 characters |
POST /appointments/{id}/rescheduleMove an appointment to another free slot
| Name | In | Required | Notes |
|---|---|---|---|
id | path | yes | Appointment id |
startAt | body | yes | New start time (ISO-8601) |
doctorId | body | no | Preferred doctor |
GET /patients/lookupFind a patient by exact mobile number or national id
| 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
| Name | In | Required | Notes |
|---|---|---|---|
id | path | yes | Patient id |
POST /patientsCreate a patient
| 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 |
| 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 |
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.createdappointment.confirmedappointment.rescheduledappointment.cancelledappointment.no_showappointment_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": "…" }
}
} 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.
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)),
);
} 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;
} 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) 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.
apiVersion: 2026-10-01 — first release: twelve appointment and patient routes, six webhook events, and branches on appointments and availability.