# Eyada API

Connect other software to Eyada, get its events by webhook, work from the terminal with the
`eyada` tool, or let an AI assistant like Claude work in the clinic for you.

- **Tokens:** Settings › API and integrations (`/api_tokens`). Make one per app or assistant.
- **Base address:** `https://eyada.ca/api/v1`
- **This page:** `https://eyada.ca/api`

## How access works

A token works as the person who made it. It can only do what **both** allow:

1. **The person's staff role** in the clinic the request works in, exactly as in the app
   (for example, a receptionist's token can't read visits).
2. **The token's scopes**, ticked when it's made. New tokens can only read.

| Scope | Allows |
| --- | --- |
| `patients:read` / `patients:write` | Search and read patients / register and update them |
| `appointments:read` / `appointments:write` | Clinics, appointments and free times / book, change and move along |
| `visits:read` | Visits, measurements, diagnoses and prescriptions |
| `measurements:read` / `measurements:write` | What the clinic measures and patients' values / record them (a scale or a blood pressure machine needs only this) |
| `orders:read` / `orders:write` | Lab and radiology orders and results / record results (a lab system needs only this) |
| `finance:read` | Transactions |

The clinic group's rules still apply: suspended people and groups, maintenance, the group's
allowed networks and two-factor policy. Opening a patient, appointment or visit is written to
the audit log under the person's name, as in the app. Platform admins can switch the API off
for a clinic group.

## Requests

```
curl https://eyada.ca/api/v1/appointments \
  -H "Authorization: Bearer YOUR_TOKEN"
```

- Send JSON bodies with `Content-Type: application/json`. Responses are JSON.
- **Clinic:** a request works in one clinic. Pick it with `clinic_id=clinic_...` (query or body)
  or an `Eyada-Clinic` header; without one, it's the first clinic the person works in.
- **Ids** are prefixed: `clinic_...`, `pat_...`, `apt_...`, `visit_...`, `txn_...`, `user_...`
  (doctors). Visit types and booking methods use plain numbers.
- **Dates** are `YYYY-MM-DD` and times ISO 8601. A time without an offset is in the clinic's
  time zone.
- **Lists** come back as `{"data": [...], "page": {"number", "pages", "count", "next"}}`. Ask for
  more with `page=2`.
- **Limits:** 300 requests a minute per token.

### Errors

Errors look like `{"error": "A sentence to show", "details": {...}}`.

| Status | Means |
| --- | --- |
| 400 | A malformed date or a missing parameter |
| 401 | No token, or it's been revoked |
| 403 | The token's scopes or the person's staff role don't allow it, or a group rule stops it |
| 404 | Not found, or not in this clinic group |
| 409 | Likely duplicate patient, or a clashing booking (see below) |
| 422 | Invalid, with `details` per field, or a status change that isn't allowed |
| 429 | Too many requests |

## Endpoints

### Clinics

`GET /clinics`: the clinics the person works in, each with `today` in its time zone and its
`clinic_types`: `general_practice` always, then any add-ons the clinic has (`pediatrics`,
`dental`, `cardiology`…), each with its `key` and `name` in the request's language. Admins set
them in Settings › Clinic types.

`GET /clinics/:id`: one clinic with its `doctors`, `visit_types` and `booking_methods`.
Use `current` as the id for the clinic the request works in.

### Patients

`GET /patients?q=`: search by name, file number, mobile or national id. Add
`include_inactive=1` to include deactivated patients.

`GET /patients/:id`: one patient, including `allergy_list` (each with `substance`, `category`,
`reaction` and `severity`), `no_known_allergies` and medical history.

`GET /patients/:id/health` (`visits:read`): the health summary: the allergy list, long-term
`conditions` (active or resolved, with `onset_on`), current `medications` (with `stops_on`
when a course ends) and medical, surgical, family and social history.

`POST /patients/:id/allergies` (`health:write`; staff who record vitals or consult) adds an
allergy: `substance`, and optionally `reaction`, `severity` (`mild`, `moderate`, `severe`) and
`category` (`medicine`, the default, `food`, `environment`, `other`). `POST /patients/:id/conditions`
(`health:write`; doctors) adds a problem: `label`, and optionally `onset_on`, `code` and
`notes`; `PATCH /patients/:id/conditions/:id` with `status` (`resolved` or `active`) resolves it
or brings it back. Each answers with the updated health summary, whose allergies and
conditions carry their `id`.

`POST /patients`: register a patient.

```json
{"patient": {"first_name": "Rami", "last_name": "Odeh", "mobile": "0791234567",
             "gender": "male", "date_of_birth": "1990-04-02"}}
```

If they look like someone already registered (same mobile, national id, or first and last
name), nothing is saved and you get **409** with the `duplicates`. Check with the person,
then send `"confirm_new": true` to register anyway.

Other fields: `middle_name`, `display_name`, `blood_group`, `national_id`, `email`, `address`,
`emergency_contact`, `occupation`, `allergies`, `medical_history`, `notes`, `registered_on`,
`provisional`, `sms_opt_out`, and `active` for people allowed to deactivate patients.
`allergies` takes text: `"Penicillin (rash), peanuts"` adds two allergies to the list, and
`"none"` records no known allergies.

`PATCH /patients/:id`: change the fields given.

### Appointments

`GET /appointments`: today's appointments in queue order. Filter with `date`, or `from` and
`to`; `status`; `doctor_id`; and `q` for the patient. Statuses: `booked`, `confirmed`,
`waiting`, `in_consultation`, `completed`, `cancelled`, `no_show`. Each appointment lists the
`next_statuses` it can move to.

`GET /appointments/:id`: one appointment.

`GET /appointments/slots?doctor_id=&date=`: a doctor's free times that day, from their
schedule, time off and the clinic's holidays.

`POST /appointments`: book.

```json
{"appointment": {"patient_id": "pat_...", "doctor_id": "user_...", "starts_at": "2026-11-05T10:30",
                 "visit_type_id": 12, "booking_method_id": 3, "notes": "Follow-up"},
 "check_in_now": true}
```

If the patient already has an appointment that day, or the doctor is taken at that time, you
get **409** with the clashing appointments in `details.conflicts`. People allowed to override
can send `"override_conflicts": true`.

`PATCH /appointments/:id`: change the doctor, time, visit type, booking method or notes.

`POST /appointments/:id/status`: move it along with `{"status": "waiting"}` (check in),
`in_consultation`, `completed`, `confirmed`, `no_show`, or `cancelled` with a `reason`.

With the Telemedicine add-on, `"video": true` books a video visit: the patient is texted a
join link (when the group sends texts), and appointments include `video` and `video_link`,
the page where the patient agrees to being seen by video and joins.

### Visits

`GET /patients/:id/visits`: a patient's visits, newest first.

`GET /visits/:id`: one visit with its `measurements`, `entries` (symptoms, signs, diagnoses,
recommendations), notes and prescriptions. `vitals` still gives weight, height, temperature,
head circumference, pulse, blood pressure and BMI in kg, cm and °C for older clients; use
`measurements` instead.

`GET /visits/:id/summary` (`visits:read`): what the patient takes home from the visit, in the
request's language: `diagnoses`, `measurements`, the finalized prescription's `medicines` with
their instructions, `tests`, `procedures`, `advice` (with `advice_notes` and `diet_advice`), the
`next_appointment` and where they were referred (`referrals`). The doctor's working notes are
left out. Staff print it from the visit, and patients see it in the portal.

### Forms

`GET /patients/:id/forms` (`visits:read`): forms filled for the patient, newest first: intake
answers sent from the portal before a visit, signed consent forms and questionnaires. Each has
the `form`, its `purpose` (`intake`, `consent`, `questionnaire` or `visit`), the `score` and
`band` for a questionnaire, `signed_by_name` and `signed_at` for consent, where it came from
(`source`: `staff`, `tablet` or `portal`), and its `answers` as questions and answers.

### Diagnosis codes

`GET /diagnosis_codes?q=` (`visits:read`), with the Diagnosis codes add-on: the group's code
set (`system`: `icd11`, or `icd10`) searched by code prefix (`CA02`) or words in English or
Arabic, best first. Each has `code`, `title`, `title_ar`, `chapter` and `leaf` (the most
specific level). Visits list `diagnoses` with each one's `label`, `code`, `code_system`,
`provisional` and `main` (the first). 403 when the group doesn't code diagnoses.

### Insurance

With the Insurance add-on. `GET /patients/:id/insurance` (`patients:read`): the patient's
`policies` (insurer, plan, `member_number`, `valid_from`, `valid_to`, `valid_today`,
`coverage_percent`, `copay_amount`) and `approvals` (service, `status`, `reference_number`,
`valid_to`). Charges for a patient with a valid policy are shared with the insurer as they're
posted: transactions of kind `insurance` credit the insurer's share.

`GET /claims` (`finance:read`): the clinic's claims, newest first, each with its `lines`, what
was paid and why lines were rejected, plus `waiting_to_claim`; `status` narrows them.

### Stock

With the Stock add-on. `GET /stock` (`stock:read`): the clinic's items with `on_hand`, the
`min_level` to keep, the `next_expiry` of stock left, how much is `expiring` within the group's
warning period, and `state` (`ok`, `low`, `out` or `expiring`); `state` narrows it.

`POST /stock/receive` (`stock:write`) adds a delivery: `stock_item_id`, `quantity`, and
optionally `lot_number` and `expires_on`. Vaccinations and procedures take what they use
automatically.

### Referrals

`GET /referrals` (`referrals:read`): the clinic's referrals to someone outside it, newest first, by
default the `open` ones (sent or booked); `status` takes `open`, `overdue`, `sent`, `booked`,
`replied`, `cancelled` or `all`. Each has the `patient`, `referred_to`, `specialty`, `reason`,
`urgency` (`routine` or `urgent`), `status`, `sent_on`, `booked_for`, `replied_on`,
`reply_notes`, the `replies` attached as files, the referral `letter`'s number, and `overdue`:
open past the group's chase period, or past its booking by the group's booked grace (14 days unless changed).

`GET /patients/:id/referrals` (`referrals:read`): the patient's referrals, newest first.

`POST /patients/:id/referrals` (`referrals:write`) records one: `referred_to` (required),
`specialty`, `reason`, `urgency` and the `visit_id` it came from. Issuing a referral letter in
Eyada records one too.

`PATCH /referrals/:id` (`referrals:write`): `status` `booked` with `booked_for`, `replied` with
`reply_notes` (and `replied_on`, today by default) or `cancelled`; or, without a status, new
details. The referring doctor is told when a reply arrives.

### Orders and results

`GET /orders` (`orders:read`): the clinic's lab and radiology orders, newest first, by default
those still waiting for results or review; `status` (`outstanding` or one status) and `kind`
(`lab`, `radiology`) narrow it.

`GET /orders/:id`: one order with each test's `components` (the parts that get results, like a
blood count's Haemoglobin, with `unit` and the `normal_range` for this patient) and its
`results` so far.

`POST /orders/:id/results` (`orders:write`) records results as values, matched by name or id:

```json
{"results": [{"test": "Complete blood count (CBC)", "component": "Haemoglobin", "value": "9.1"},
             {"test": "HbA1c", "component": "HbA1c", "value": "7.4"}]}
```

Each value is flagged `low`, `high`, `critical_low` or `critical_high` against the patient's
range, the order moves to results attached, and the doctor who ordered it is told about anything
out of range. A name that doesn't match is refused with 422 and nothing is saved.

`GET /patients/:id/results` (`orders:read`): the patient's results, newest first, with `name`,
`value`, `unit`, `text`, `normal_range` and `flag`; `test=HbA1c` for one trend.

### Procedures and treatment plans

`GET /patients/:id/procedures` (`visits:read`): procedures done in the patient's visits, newest
first, with `label`, `site`, `consumables`, `lot_number`, `consent`, `notes`, `performed_on`,
what was `charged` (or `from_package`) and the `treatment_plan_id` when it was a planned session.
Visits list their `procedures` too.

`GET /patients/:id/treatment_plans` (`visits:read`): each plan's `title`, `status` (`proposed`,
`accepted`, `completed` or `cancelled`), `total` estimate and sessions done of planned, with its
`items`.

### Specialty charts

`GET /patients/:id/charts` (`visits:read`): the charts the clinic group's add-ons bring, named
in `charts`, and only those. `teeth`, `body` and `eyes` list chart marks, oldest first, with the
`site` (an FDI tooth number with `surfaces` such as `MO`, a body region such as
`front:knee_left` with `x` and `y` as percentages of the view, or an eye part such as
`right:cornea`), `where` in words, `label`, `status` (`finding`, `existing`, `planned`, `done`
or `resolved`), `score` (pain, 0 to 10), `notes`, and the `procedure` and `treatment_plan_id`
when work was planned or done. `eye_exams` give each eye's acuity (`va_unaided`,
`va_corrected`, `va_near`), refraction (`sphere`, `cylinder`, `axis`, `add`, and as text) and
pressure (`iop`, mmHg). `audiograms` give `thresholds` in dB HL by series (`right_air`,
`left_air`, `right_bone`, `left_bone`) and frequency, each ear's four-tone `average` and its WHO
`grade`. `growth` gives children's `weight`, `height`, `head_circumference` and `bmi` with the
`age_months`, WHO `z_score` and `percentile`. `pregnancies` give the `due_on`, how it was
`dated_by`, the `weeks` of an open one, its `risk_factors`, its antenatal `visits` (each day's
`date`, `weeks` and `measurements` by key) and the `outcome` of a closed one. `weight_goal` is in kg.
`vaccine_schedule` lists each scheduled dose with its `due_on` and `status` (`given`,
`overdue`, `due` or `upcoming`) and when it was `given_on`; `safety_plan` gives its sections.
Private session notes are never sent through the API.

### Follow-ups and recalls

`POST /visits/:id/follow_up` (`appointments:write`) books the patient's follow-up at the first
free time with the visit's doctor, `after` the visit (`1w`, `2w`, `1m`, `3m` or `6m`) or `on` or
after a day, looking three weeks ahead. It returns the appointment, or 422 when nothing is free.

`GET /recalls` (`appointments:read`): patients due back under the group's recall rules and
vaccinations coming due, `window` `overdue`, `30` (the default) or `90` days, optionally one
`rule` (`rule_1`, or `vaccinations`). Each has the `patient`, its `name` (what it's for), `due_on`,
`last_done_on`, `overdue`, and `state`: `to_contact`, `contacted` or `booked`.

### Visit form and phrases

`GET /visit_form`: the sections a visit has in this clinic (`measurements`, `symptoms`,
`signs`, `diagnoses`, `recommendations`, `diet_advice`), whether each is `shown` and whether it's
`required` before the visit can be completed. Admins set them in Settings › Visit form.

`GET /phrases`: the person's phrases and the group's `shared` ones: each `code` (typed as
`/code` in the app) with its `body` and `body_ar`.

### Measurements

Each clinic group chooses what it measures and its units in Settings › Measurements.

`GET /measurement_types`: what the clinic measures, with each one's `key`, `kind` (`number`,
`pair` such as blood pressure, or `calculated` such as BMI), `unit` (the group's choice, so
°C or °F, kg or lb, mg/dL or mmol/L), `normal_range`, `plausible_range` and `qualifiers`
(how it was taken, like `Fasting`).

`GET /patients/:id/measurements`: a patient's values, newest first; `type=weight` for one
trend. Each has `value` (and `value2` for pairs) in the group's unit, `text` such as
`120/80 mmHg`, `flag` (`high`, `low` or null against the normal range) and `recorded_at`.

`POST /patients/:id/measurements` records values on the `visit_id` or `appointment_id` given,
or else on the patient's appointment today in this clinic (its visit once the doctor has
started it), and marks the appointment's vitals as taken:

```json
{"measurements": {"weight": {"value": 72.5}, "blood_pressure": {"value": 120, "value2": 80, "qualifier": "Sitting"}}}
```

A blank `value` removes that measurement. A value outside the plausible range is refused with
422 unless `override_reason` says why it's right. Tokens made before measurements existed need
the scope ticked.

### Transactions

`GET /transactions`: newest first. Filter with `from`, `to`, `kind` (`charge`, `payment`,
`discount`, `refund`, `income`, `expense`), `patient_id` and `q`.

`GET /transactions/:id`: one transaction.

## AI assistants (MCP)

Eyada is an MCP server at `https://eyada.ca/mcp` (Streamable HTTP). Add that address as a
connector in Claude, ChatGPT or any assistant that supports MCP, and it signs in with one
click: you sign in to Eyada, choose the clinic group and whether it may change things or only
look, and press Allow. In Claude Code:

```
claude mcp add --transport http eyada https://eyada.ca/mcp
```

It shows as a connected app in Settings › API and integrations, where you can revoke it. Its
access lasts an hour and renews itself for up to 90 days of use. Sign-in follows OAuth 2.1
with PKCE and dynamic client registration (`/.well-known/oauth-authorization-server`).

Scripts and assistants that can't sign in this way can send a token instead, with
`--header "Authorization: Bearer YOUR_TOKEN"`.

Each tool calls the API above with the same token, so the assistant sees only the tools the
token's scopes allow, for the features the clinic group has switched on (no lab tools where
orders are off, no money tools where finance is), and can do only what the person can:
`list_clinics`, `get_clinic`, `search_patients`, `get_patient`, `register_patient`,
`update_patient`, `list_appointments`, `get_appointment`, `find_free_slots`, `book_appointment`,
`update_appointment`, `change_appointment_status`,
`get_patient_health`, `record_allergy`, `add_problem`, `resolve_problem`, `list_patient_visits`, `get_visit`, `get_visit_summary`, `search_diagnosis_codes`, `list_stock`, `receive_stock`, `get_patient_insurance`, `list_claims`, `list_patient_forms`,
`list_referrals`, `list_patient_referrals`, `refer_patient`, `update_referral`, `list_orders`, `get_order`, `record_results`,
`list_patient_results`, `list_patient_procedures`, `get_patient_charts`,
`list_treatment_plans`, `book_follow_up`, `list_recalls`,
`get_visit_form`, `list_phrases`,
`list_measurement_types`, `list_patient_measurements`,
`record_measurements`, `list_transactions` and `get_transaction`. Every tool takes an optional
`clinic_id`. A read-only token is a good start for an assistant.

## Webhooks

Webhooks send your clinic group's events to another system the moment they happen.
Administrators add them in Settings › Webhooks: an `https` address, a name, and the events
to send (none ticked means all of them, including ones added later).

| Event | When |
| --- | --- |
| `patient.registered` | A patient is registered, at the desk, through the API or online |
| `appointment.booked` | An appointment is booked |
| `appointment.rescheduled` | Its time changes; `data.previous_starts_at` has the old one |
| `appointment.status_changed` | Confirmed, checked in, seen, completed, cancelled or no-show; `data.previous_status` has the old one |
| `visit.completed` | A doctor completes a visit |
| `transaction.posted` | A charge, payment, discount, refund, income or expense is posted |
| `transaction.reversed` | One is reversed |

Each event is a `POST` with a JSON body:

```json
{"id": "evt_...", "type": "appointment.booked", "created_at": "2026-11-06T09:20:00Z",
 "account_id": "acct_...", "data": { ...the appointment, as GET /appointments/:id returns it... }}
```

Payloads carry ids, names, times and statuses, never clinical notes: `visit.completed` names
the patient, doctor and day only. Fetch more through the API if you need it.

Headers: `Eyada-Event` (the type), `Eyada-Delivery` (unique per attempt, `whd_...`) and
`Eyada-Signature: t=<unix time>,v1=<signature>`, where the signature is the hex HMAC-SHA256
of `<t>.<raw body>` with the webhook's signing secret. Check it, and that `t` is within a few
minutes, before trusting a request:

```ruby
t, v1 = request.headers["Eyada-Signature"].scan(/t=(\d+),v1=(\h+)/).first
expected = OpenSSL::HMAC.hexdigest("SHA256", ENV["EYADA_WEBHOOK_SECRET"], "#{t}.#{request.raw_post}")
ok = Rack::Utils.secure_compare(expected, v1) && (Time.now.to_i - t.to_i).abs < 300
```

Answer with any `2xx` within 10 seconds. Anything else is retried after 1 minute, 5 minutes,
30 minutes, 2 hours and 8 hours, and can be sent again by hand from the webhook's page. After
15 failures in a row the webhook is switched off and administrators are told. Use the
`id` to ignore an event you've already handled. "Send a test" sends a `webhook.ping`.

## Command line

`eyada` is a single file that needs only Ruby (macOS and Linux have it).

```
curl -fsSL https://eyada.ca/cli/eyada -o eyada && chmod +x eyada
./eyada login                 # paste a token
./eyada appointments          # today's queue
./eyada patients omar         # search
./eyada book pat_... --at "2026-11-05 10:30" --doctor user_... --check-in
./eyada measure pat_... weight=72.5 blood_pressure=120/80@Sitting
./eyada health pat_...        # allergies, problems, medicines
./eyada follow-up visit_... --in 2w
./eyada recalls --window overdue
./eyada plans pat_...         # treatment plans and their progress
./eyada results pat_... --type HbA1c
./eyada summary visit_...     # the patient's take-home summary
./eyada referrals --status overdue
./eyada referral ref_... replied --notes "Echo normal, continue treatment"
./eyada check-in apt_...
./eyada help                  # everything else
```

Every command takes `--clinic clinic_...` and `--json`. The sign-in is saved in
`~/.config/eyada/config.json`, readable only by you; `EYADA_URL`, `EYADA_TOKEN` and
`EYADA_CLINIC` override it, which suits scripts.
