Basmtak Clinics developer platform

Partner API v1: read patients, appointments and invoices, book and cancel appointments, receive signed webhooks.

العربية

Base URL https://demo.clinics.basmtak.com/partner/v1 · OpenAPI 3.0.3 · Download the OpenAPI file

Read a clinic's patients, appointments and invoices, book and cancel appointments, and receive signed webhooks (appointment.*, invoice.*, claim.*, patient.erased). Money is always integer halalas (1 SAR = 100); ids of clinical rows are ULIDs; times are ISO-8601 in the branch's zone. Errors are JSON {error, message, details}; the message follows Accept-Language (ar or en). Lists answer {data, meta} and page with ?page and ?per_page (at most 100). No diagnoses, clinical notes or photos ever travel through this API.

Downloads

All three are generated from the same OpenAPI file as this page, so they always match the live API. No sign-in is needed.

OpenAPI 3 (YAML) · OpenAPI 3 (JSON) · Postman collection (v2.1)

Import it into Postman, set the {{token}} variable to your client token and run any request. «Subscribe» stores {{webhook_id}} and {{webhook_secret}}; the «Webhook signature» folder signs a sample delivery and sends it to {{receiver_url}}, so you can test your receiver before a real event happens.

Authentication

The account owner creates an API client under Office → Developers and chooses its abilities. The token is shown once. Send it on every call:

curl -H "Authorization: Bearer bmc_…" -H "Accept-Language: ar" https://demo.clinics.basmtak.com/partner/v1/ping

The token can also be sent as X-Api-Key. GET /ping answers the client, its abilities and the clinic: use it as the «test connection» call.

Abilities

Each endpoint needs one ability; a call without it answers 403 insufficient_ability with details.required_ability. webhooks only manages subscriptions: to receive an event, or list its recent objects, the client also needs the ability that reads that data.

AbilityEndpoints
anyGET /ping · GET /meta
patients:readGET /patients · GET /patients/{id}
appointments:readGET /appointments · GET /appointments/{id}
appointments:writePOST /appointments · POST /appointments/{id}/cancel
invoices:readGET /invoices · GET /invoices/{id}
webhooksGET /webhooks · POST /webhooks · GET /webhooks/events · GET /webhooks/events/{event}/recent · GET /webhooks/{id} · PUT /webhooks/{id} · DELETE /webhooks/{id} · POST /webhooks/{id}/rotate · POST /webhooks/{id}/test

Rate limit

120 requests a minute per API client. A 429 answer carries Retry-After; every answer carries X-RateLimit-Limit and X-RateLimit-Remaining.

Calls whose token matches no active client share a per-address limit of 30 a minute. A plan without the Partner API feature answers 403 not_entitled.

Errors

Every error is JSON {error, message, details}; the message follows Accept-Language (ar or en). Money is always integer halalas (1 SAR = 100).

{"error": "insufficient_ability", "message": "…", "details": {"required_ability": "appointments:write"}}
CodeHTTPMeaning
unauthenticated401Missing API token. Send it as "Authorization: Bearer bmc_…".
token_invalid401This API token is not valid or was revoked.
tenant_inactive403This clinic's account is suspended or closed.
not_entitled403The account's plan does not include the Partner API.
insufficient_ability403This API client does not have the ability this call needs.
forbidden403Only the account owner can manage API clients and webhooks.
not_found404Not found.
client_revoked409This API client was revoked.
webhook_disabled409This webhook is switched off.
delivery_sent409This delivery already succeeded.
payload_redacted409This delivery's body was removed (retention or a patient's erasure); it cannot be sent again.
validation_failed422Some fields are not valid.
event_unknown422Unknown webhook event.
url_not_allowed422This URL cannot receive webhooks (HTTPS on a public address only).
webhook_limit422Too many webhook subscriptions.
rate_limited429Too many requests. Try again shortly.
not_implemented501This endpoint is documented but not available yet; it answers once its package ships.

planned

Endpoints marked «planned» are fully documented here and their abilities are enforced, but they answer 501 not_implemented until their package ships (patients, appointments, invoices). Build against this file; the shapes are fixed.

Pagination

Lists answer {data, meta: {total, page, per_page}}. Use ?page= and ?per_page= (default 50, at most 100).

{"data": [ … ], "meta": {"total": 312, "page": 2, "per_page": 50}}

Retries and idempotency

POST /appointments is idempotent on external_ref per API client: sending the same booking again answers 200 with the same appointment, never a second one. Cancelling an appointment twice is a no-op. On a timeout, read before you retry. Webhook deliveries may arrive more than once: drop repeats by X-Basmtak-Event-Id (the body's id).

Webhooks (REST hooks)

Subscribe with POST /webhooks {target_url, event} and unsubscribe with DELETE /webhooks/{id} — the Zapier and Make REST-hook pattern. Each delivery is a POST of {id, event, tenant_id, occurred_at, api_version, data}, retried with exponential backoff; 20 failures in a row (or a 410 answer) switch the subscription off. Deliveries never carry diagnoses, clinical notes or photos.

What a delivery looks like

A POST with Content-Type application/json and the body {id, event, tenant_id, occurred_at, api_version, data}; data has the same shape the API returns for that object. Headers: X-Basmtak-Event, X-Basmtak-Event-Id, X-Basmtak-Delivery, X-Basmtak-Timestamp and X-Basmtak-Signature.

Verifying a delivery

X-Basmtak-Signature = "sha256=" + hex HMAC-SHA256(secret, X-Basmtak-Timestamp + "\n" + "POST" + "\n" + path + "\n" + hex SHA-256(raw body)). Reject timestamps older than five minutes. X-Basmtak-Event-Id is unique per event — use it to drop duplicates.

Worked example

With these inputs your code must produce exactly this signature (computed live by the code that signs real deliveries). Use the secret exactly as you received it, including whsec_, and the raw body bytes, before any JSON parsing.

Secretwhsec_example0000000000000000000000000000000000
X-Basmtak-Timestamp1798761600
Path of your URL (no query string)/hooks/standard/123/abc
Raw body{"id":"7d3f0c52-1a4b-4c8e-9f21-6b0d2e5a9c11","event":"ping","tenant_id":1,"occurred_at":"2027-01-01T03:00:00+03:00","api_version":"v1","data":{}}
SHA-256 of the body (hex)d950161f336317f2a060361c620449e231a81b783e5ad56dd34940641a0219b6
Canonical string (lines joined by \n)
1798761600
POST
/hooks/standard/123/abc
d950161f336317f2a060361c620449e231a81b783e5ad56dd34940641a0219b6
X-Basmtak-Signaturesha256=ced5fbb70b35667445f95e9f3e74b1db659649cbbc99eec039b69d010ccead26
// Node.js (Express: app.use(express.raw({type: 'application/json'})))
const crypto = require('crypto');
function verify(req, secret) {
  const ts = req.get('X-Basmtak-Timestamp');
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const path = req.originalUrl.split('?')[0];
  const canonical = ts + "\nPOST\n" + path + "\n" + crypto.createHash('sha256').update(req.body).digest('hex');
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(canonical).digest('hex');
  const given = req.get('X-Basmtak-Signature') || '';
  return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
<?php // PHP
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_BASMTAK_TIMESTAMP'] ?? '';
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$canonical = $ts . "\nPOST\n" . $path . "\n" . hash('sha256', $body);
$ok = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
   && hash_equals('sha256=' . hash_hmac('sha256', $canonical, $secret), $_SERVER['HTTP_X_BASMTAK_SIGNATURE'] ?? '');
# Python
import hashlib, hmac, time
def verify(secret: str, ts: str, path: str, body: bytes, signature: str) -> bool:
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    canonical = f"{ts}\nPOST\n{path}\n{hashlib.sha256(body).hexdigest()}"
    expected = "sha256=" + hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Retries and switching off

Any 2xx answer within 10 seconds is a success. Anything else (another status, a timeout, a refused address) is retried after 60 seconds, doubling each time up to 6 hours; after 10 attempts the delivery is given up. 20 failed attempts in a row switch the subscription off, and a 410 Gone answer switches it off at once (the REST-hook way to unsubscribe). Only the status code is kept, never your answer's body.

Events

appointment.created Ability to receive: appointments:read

Sample data

{
    "id": "01J9ZK3Q7R8S9T0V1W2X3Y4Z5A",
    "patient_id": "01J9ZJ0000000000000000000P",
    "branch_id": 1,
    "practitioner_id": "01J9ZJ0000000000000000000D",
    "appointment_type_id": 3,
    "status": "booked",
    "starts_at": "2027-04-02T18:00:00+03:00",
    "ends_at": "2027-04-02T18:30:00+03:00",
    "room_id": 2,
    "source": "partner",
    "external_ref": "crm-1001",
    "note": null,
    "created_at": "2027-04-01T09:12:07+03:00",
    "updated_at": "2027-04-01T09:12:07+03:00"
}
appointment.updated Ability to receive: appointments:read

Sample data

{
    "changes": {
        "starts_at": [
            "2027-04-02T18:00:00+03:00",
            "2027-04-02T18:30:00+03:00"
        ]
    },
    "id": "01J9ZK3Q7R8S9T0V1W2X3Y4Z5A",
    "patient_id": "01J9ZJ0000000000000000000P",
    "branch_id": 1,
    "practitioner_id": "01J9ZJ0000000000000000000D",
    "appointment_type_id": 3,
    "status": "booked",
    "starts_at": "2027-04-02T18:00:00+03:00",
    "ends_at": "2027-04-02T18:30:00+03:00",
    "room_id": 2,
    "source": "partner",
    "external_ref": "crm-1001",
    "note": null,
    "created_at": "2027-04-01T09:12:07+03:00",
    "updated_at": "2027-04-01T09:12:07+03:00"
}
appointment.cancelled Ability to receive: appointments:read

Sample data

{
    "status": "cancelled",
    "cancel_reason": "patient_request",
    "cancelled_at": "2027-04-02T10:00:00+03:00",
    "id": "01J9ZK3Q7R8S9T0V1W2X3Y4Z5A",
    "patient_id": "01J9ZJ0000000000000000000P",
    "branch_id": 1,
    "practitioner_id": "01J9ZJ0000000000000000000D",
    "appointment_type_id": 3,
    "starts_at": "2027-04-02T18:00:00+03:00",
    "ends_at": "2027-04-02T18:30:00+03:00",
    "room_id": 2,
    "source": "partner",
    "external_ref": "crm-1001",
    "note": null,
    "created_at": "2027-04-01T09:12:07+03:00",
    "updated_at": "2027-04-01T09:12:07+03:00"
}
invoice.issued Ability to receive: invoices:read

Sample data

{
    "id": "01J9ZM0000000000000000000I",
    "number": "INV-2027-000418",
    "kind": "simplified",
    "status": "issued",
    "patient_id": "01J9ZJ0000000000000000000P",
    "branch_id": 1,
    "buyer_kind": "citizen",
    "currency": "SAR",
    "issued_at": "2027-04-02T18:41:07+03:00",
    "subtotal_halalas": 30000,
    "discount_halalas": 0,
    "vat_halalas": 4500,
    "total_halalas": 34500,
    "paid_halalas": 0,
    "due_halalas": 34500,
    "tax_breakdown": [
        {
            "tax_category": "state_borne",
            "net_halalas": 30000,
            "vat_halalas": 4500
        }
    ],
    "lines": [
        {
            "line_no": 1,
            "code": "97012",
            "description": "حشوة مركبة — سطح واحد",
            "tooth": "36",
            "surfaces": [
                "O"
            ],
            "qty": 1,
            "unit_halalas": 30000,
            "net_halalas": 30000,
            "tax_category": "state_borne",
            "vat_halalas": 4500,
            "total_halalas": 34500
        }
    ]
}
invoice.paid Ability to receive: invoices:read

Sample data

{
    "status": "paid",
    "paid_halalas": 34500,
    "due_halalas": 0,
    "paid_at": "2027-04-02T18:45:00+03:00",
    "payments": [
        {
            "method": "card",
            "amount_halalas": 34500,
            "scheme": "mada",
            "last4": "4242"
        }
    ],
    "id": "01J9ZM0000000000000000000I",
    "number": "INV-2027-000418",
    "kind": "simplified",
    "patient_id": "01J9ZJ0000000000000000000P",
    "branch_id": 1,
    "buyer_kind": "citizen",
    "currency": "SAR",
    "issued_at": "2027-04-02T18:41:07+03:00",
    "subtotal_halalas": 30000,
    "discount_halalas": 0,
    "vat_halalas": 4500,
    "total_halalas": 34500,
    "tax_breakdown": [
        {
            "tax_category": "state_borne",
            "net_halalas": 30000,
            "vat_halalas": 4500
        }
    ],
    "lines": [
        {
            "line_no": 1,
            "code": "97012",
            "description": "حشوة مركبة — سطح واحد",
            "tooth": "36",
            "surfaces": [
                "O"
            ],
            "qty": 1,
            "unit_halalas": 30000,
            "net_halalas": 30000,
            "tax_category": "state_borne",
            "vat_halalas": 4500,
            "total_halalas": 34500
        }
    ]
}
claim.updated Ability to receive: invoices:read

Sample data

{
    "id": "01J9ZN0000000000000000000C",
    "invoice_id": "01J9ZM0000000000000000000I",
    "patient_id": "01J9ZJ0000000000000000000P",
    "payer_id": 4,
    "status": "adjudicated",
    "previous_status": "submitted",
    "outcome": "partial",
    "claimed_halalas": 34500,
    "approved_halalas": 30000,
    "patient_share_halalas": 4500,
    "nphies_ref": "CLM-7F3A2",
    "updated_at": "2027-04-05T11:20:00+03:00"
}
patient.erased Ability to receive: patients:read

Sample data

{
    "id": "01J9ZJ0000000000000000000P",
    "erased_at": "2027-06-01T08:00:00+03:00"
}

REST hooks for Zapier and Make

The API offers everything the Zapier REST Hook trigger and Make's instant (webhook) trigger need. Authentication is an API key sent as a bearer token.

StepCallAnswer
Test connectionGET /ping200 with the client, its abilities and the clinic
SubscribePOST /webhooks {"target_url": "…", "event": "appointment.created"}201 {id, secret, data}; keep id for unsubscribing (the secret is shown only here)
UnsubscribeDELETE /webhooks/{id}204; a 410 Gone answer from your hook URL does the same
Perform list (sample data)GET /webhooks/events/{event}/recent?limit=3the newest real objects of that event, in the delivery envelope; meta.sample is a static sample for an empty clinic
Event listGET /webhooks/eventsevery event with a sample
curl -X POST https://demo.clinics.basmtak.com/partner/v1/webhooks -H "Authorization: Bearer bmc_…" -H "Content-Type: application/json" \
     -d '{"target_url": "https://hooks.zapier.com/hooks/standard/123/abc", "event": "appointment.created"}'
{"id": 41, "secret": "whsec_…", "data": {"id": 41, "event": "appointment.created", "target_url": "https://hooks.zapier.com/hooks/standard/123/abc", …}}

curl https://demo.clinics.basmtak.com/partner/v1/webhooks/events/appointment.created/recent?limit=1 -H "Authorization: Bearer bmc_…"
{"data": [{"id": "appointment.created:01J9ZK…", "event": "appointment.created", "tenant_id": 7, "occurred_at": "…", "api_version": "v1", "data": {…}}], "meta": {"event": "appointment.created", "count": 1, "sample": {…}}}

curl -X DELETE https://demo.clinics.basmtak.com/partner/v1/webhooks/41 -H "Authorization: Bearer bmc_…"   # 204

The hook URL must be HTTPS on a public address (hooks.zapier.com and hook.*.make.com are). A Zap or scenario that only needs actions (book or cancel an appointment) or searches (a patient by file number or phone, an invoice) calls the endpoints below directly.

Endpoints

meta Connectivity and what this server offers
GET /ping Check the token and see the client's abilities Ability: any

Responses

200The client, its abilities and the clinic
401{error, message, details}Error
GET /meta Abilities, the event catalogue, rate limits and the spec version Ability: any

Responses

200{data: {api_version, spec_version, abilities, client_abilities, events, rate_limits, per_page, webhooks, docs_url, openapi_url}}
patients The patient read subset (demographics and identifiers, never clinical content)
GET /patients List patients Ability: patients:read

Demographics and identifiers only. Search by file_no, national id or phone through the filters below.

Parameters

pagequeryinteger
per_pagequeryinteger
file_noquerystring
phonequerystringE.164
updated_sincequerystringISO-8601; only patients changed since then

Responses

200A page of patientsPatientPage
GET /patients/{id} One patient Ability: patients:read

Parameters

idpathstring · requiredULID (26 characters)

Responses

200The patientobject
404{error, message, details}Error
appointments Appointments, booking and cancellation
GET /appointments List appointments Ability: appointments:read

Parameters

pagequeryinteger
per_pagequeryinteger
branch_idqueryinteger
patient_idquerystring
practitioner_idquerystring
fromquerystringISO-8601 date or date-time (inclusive)
toquerystringISO-8601 date or date-time (exclusive)
statusquerystring

Responses

200A page of appointmentsAppointmentPage
POST /appointments Book an appointment Ability: appointments:write

Books a free slot (GET the clinic's slots through the portal rules the SCHEDULING package publishes). external_ref makes the call idempotent per API client: the same external_ref answers 200 with the same appointment, never a second booking. The patient is named by id or by phone (a new patient file is opened when the phone is unknown).

Request body AppointmentWrite

{
    "branch_id": 1,
    "practitioner_id": "01J9ZJ0000000000000000000D",
    "appointment_type_id": 3,
    "starts_at": "2027-04-02T18:00:00+03:00",
    "patient_id": "01J9ZJ0000000000000000000P",
    "external_ref": "crm-1001",
    "note": "يفضل الفترة المسائية"
}

Responses

201The appointmentobject
200The same external_ref again — the existing appointment, with duplicate true
409{error, message, details}Error
422{error, message, details}Error
GET /appointments/{id} One appointment Ability: appointments:read

Parameters

idpathstring · requiredULID (26 characters)

Responses

200The appointmentobject
404{error, message, details}Error
POST /appointments/{id}/cancel Cancel an appointment Ability: appointments:write

Cancelling twice is a no-op (200). A completed or in-progress appointment answers 409 appointment_locked.

Parameters

idpathstring · requiredULID (26 characters)

Request body object

{
    "reason": "patient_request"
}

Responses

200The cancelled appointment
404{error, message, details}Error
409{error, message, details}Error
invoices Issued invoices and their payment state
GET /invoices List issued invoices Ability: invoices:read

Parameters

pagequeryinteger
per_pagequeryinteger
patient_idquerystring
branch_idqueryinteger
statusquerystring
issued_fromquerystringISO-8601 date (inclusive)
issued_toquerystringISO-8601 date (exclusive)

Responses

200A page of invoicesInvoicePage
GET /invoices/{id} One invoice with its lines and payments Ability: invoices:read

Parameters

idpathstring · requiredULID (26 characters)

Responses

200The invoiceobject
404{error, message, details}Error
webhooks REST hooks (Zapier and Make)
GET /webhooks Your subscriptions Ability: webhooks

Parameters

pagequeryinteger
per_pagequeryinteger

Responses

200A page of subscriptions
POST /webhooks Subscribe (REST hook) Ability: webhooks

The answer carries the subscription id and its signing secret. The secret is shown only here. The client must hold the ability that reads each event's data (appointments:read, invoices:read, patients:read).

Request body WebhookWrite

{
    "target_url": "https://hooks.zapier.com/hooks/standard/123/abc",
    "event": "appointment.created"
}

Responses

201{id, secret, data}
403{error, message, details}Error
422{error, message, details}Error
GET /webhooks/events The events, each with the ability it needs and sample data Ability: webhooks

Responses

200{data: [{event, ability, sample}]}
GET /webhooks/events/{event}/recent Recent real objects of one event (REST hook perform list) Ability: webhooks

The newest real objects of one event, each wrapped in the envelope a delivery carries {id, event, tenant_id, occurred_at, api_version, data}, newest first. Zapier calls this as the REST Hook trigger's perform list when a user tests a Zap; Make uses it to fetch sample data. id is event:object-id (stable, for de-duplication), not the uuid of a live delivery. Besides webhooks the client needs the ability that receives the event. meta.sample is the static sample, for a clinic with no data yet; data is empty until the event's package publishes its recent objects.

Parameters

eventpathstring · required
limitqueryinteger1 to 25, default 3

Responses

200{data: [{id, event, tenant_id, occurred_at, api_version, data}], meta: {event, count, sample}}
403{error, message, details}Error
422{error, message, details}Error
GET /webhooks/{id} One subscription Ability: webhooks

Parameters

idpathinteger · required

Responses

200The subscription
404{error, message, details}Error
PUT /webhooks/{id} Change the URL, the events, the description or switch it on or off Ability: webhooks

Parameters

idpathinteger · required

Request body WebhookWrite

{
    "events": [
        "appointment.created",
        "appointment.cancelled"
    ],
    "status": "active"
}

Responses

200The subscription
403{error, message, details}Error
404{error, message, details}Error
409{error, message, details}Error
422{error, message, details}Error
DELETE /webhooks/{id} Unsubscribe (REST hook) Ability: webhooks

Parameters

idpathinteger · required

Responses

204Removed
404{error, message, details}Error
POST /webhooks/{id}/rotate Mint a new signing secret (the old one stops at once) Ability: webhooks

Parameters

idpathinteger · required

Responses

200{id, secret, data} — the new secret, shown only here
404{error, message, details}Error
POST /webhooks/{id}/test Send a signed ping to this subscription now Ability: webhooks

Delivers a `ping` event (data {subscription_id, message}) and answers the delivery row with its status and the receiver's HTTP status.

Parameters

idpathinteger · required

Responses

200{data: Delivery}
404{error, message, details}Error
409{error, message, details}Error