Authentication
Send your API key on every request, keep it secret, and name the patient on OTP-gated routes.
Every server-side request carries your API key. There is no token to sign and no keypair to manage.
1. Create a key
In the dashboard, go to Workspace Settings → API Keys
(/dashboard/workspace-settings/api-keys).
Choose Add API Key, set Key Type to Developer API.
Add the IP addresses your servers call from. At least one is required — see IP allowlist.
Create the key and copy the API key (mk_…).
You see the key in full exactly once
After you close that dialog the dashboard shows it masked, for good, and there is no way to retrieve it. Put it in your secret manager before you do anything else. Lost it? Rotate — create a replacement and retire the old one.
2. Send it
x-api-key: mk_…That is the whole credential for most routes.
const res = await fetch(
"https://api.medos.one/v1/appointments/available-slots?addressId=1&doctorId=5&appointmentDate=2026-09-20&mode=OFFLINE",
{ headers: { "x-api-key": process.env.MEDOS_API_KEY } },
);import os, requests
requests.get(
"https://api.medos.one/v1/appointments/available-slots",
params={"addressId": 1, "doctorId": 5, "appointmentDate": "2026-09-20", "mode": "OFFLINE"},
headers={"x-api-key": os.environ["MEDOS_API_KEY"]},
)HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.medos.one/v1/appointments/available-slots"
+ "?addressId=1&doctorId=5&appointmentDate=2026-09-20&mode=OFFLINE"))
.header("x-api-key", System.getenv("MEDOS_API_KEY"))
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());Keeping the key safe
The API key authenticates on its own, so anyone holding it can call the API as you — from an address on your allowlist. Treat it like a database password.
| Do | Don't |
|---|---|
| Keep it in a secret manager or an environment variable | Commit it to a repository, even a private one |
| Send it only from server-side code, over HTTPS | Put it in a browser, a mobile app or a desktop client |
| Register the narrowest addresses you can | Register a whole cloud provider's range if you can pin your egress |
| Rotate it when someone with access leaves | Share one key across unrelated integrations |
The allowlist is your safety net
If the key does leak, the allowlist is what stops it being used — a request
from any address you did not register is refused with a 403, even with the
right key. Rotate anyway: an allowlist bounds a leak, it does not undo one.
A key that should never run in a browser is also refused if you try: the widget's session endpoint will not accept a Developer API key, and a Developer API route will not accept a widget key. The two kinds of key do not stand in for each other.
OTP-gated routes
Booking and patient lookup require a patient's phone to have been verified by one-time code in the last 30 minutes. It is the patient-consent check, not an anti-bot measure. If your system has already identified the patient, book through the routes without OTP instead.
Because one API key serves every patient you have, these routes need one more header, naming the patient the request is for:
x-api-key: mk_…
x-end-user-id: patient-8841POST /v1/otp/send with { countryCode, phoneNumber } and x-end-user-id.
POST /v1/otp/verify with { countryCode, phoneNumber, otpCode } and the
same x-end-user-id.
Call the gated route within 30 minutes, with the same x-end-user-id.
What to use as the value. Your own identifier for that patient — a patient id or a CRM id is ideal. It must be stable across the three calls and distinct per patient. It is hashed before Medos stores it, so an email address works too, though an opaque id is better practice. It must be 1–128 printable ASCII characters, with no spaces.
Why the header is required, not optional
Verification is tracked per x-end-user-id under your key. If it were tracked
per key alone, verifying patient A and then patient B would overwrite A's
verification — and A's next booking would run as B. So the OTP routes and every
gated route answer 400 without the header rather than guess. Routes that are
not gated do not need it.
The daily limit on one-time codes (50 per 24 hours) is counted per
x-end-user-id too, so each patient has their own allowance.
Gated routes are marked 🔒 in Endpoints.
Booking without OTP
The one-time code exists for patient-facing screens, where nothing else proves that the person booking owns the phone they typed. A server integration often knows its patient already — a front desk, a CRM, a patient app with its own login — and sending them a WhatsApp code adds nothing.
So the gate follows the credential, not the path. On the booking routes:
| Calling with | OTP gate |
|---|---|
Authorization: Bearer st_… (a widget session) | Enforced. The phone must have been verified in the last 30 minutes. |
x-api-key (a server integration) | Skipped. Your server is trusted to have identified its patient. |
The routes are the documented ones — there is no second set of paths:
POST /v1/appointments/book-appointmentPOST /v1/appointments/book-appointment-unifiedPOST /v1/appointments/reserve-slotPOST /v1/qms-appointments/pre/bookPOST /v1/qms-appointments/immediate/book
Same body, same response. Two things differ when you call with an API key:
- No
x-end-user-id. There is no verification to keep separate per patient. - Your workspace is pinned. A
workspaceIdin the body is overwritten with the one your key belongs to, and the booking is stamped as coming from the Developer API.