Developers
Public API and webhooks
127 operations · 30 webhook events · 7 scopes
POST /oauth/token
Authorization: Basic ‹client_id:client_secret›
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=patients:read encounters:read
→ 200 { "token_type": "Bearer", "expires_in": 3600 }Authentication
From API client to first call
- 01
Create an API client
A clinic admin opens Settings, API and webhooks, then picks scopes, branches, and a rate limit. The client secret is shown only once.
- 02
Exchange it for a token
POST /oauth/token with Basic auth and grant_type=client_credentials. The opaque token is valid for 3,600 seconds; request a new one before it expires.
- 03
Call endpoints
Send Authorization: Bearer ‹token›. Choose a branch with X-Branch-ID when the client covers several branches.
- 04
Revoke at any time
Changing scopes, rotating the secret, or revoking the client immediately invalidates all of its active tokens. Every access is recorded in the clinic audit log.
Authentication and signatures
OAuth2 client credentials
A clinic admin creates an API client; the integrator exchanges the credentials for a Bearer token valid for 1 hour.
Scope = permission
Scopes map to RBAC permissions, so endpoints enforce the same access rights as app users.
Signed webhooks
Header X-Medivana-Signature: t=‹unix›,v1=HMAC-SHA256(secret, "‹t›." + body); reject when the time difference exceeds 5 minutes.
Quickstart
No SDK needed: curl, JavaScript, Python
Two requests are enough to read your first data. Keep credentials in environment variables and never ship the client secret in a browser or mobile app.
1. Request an access token
curl -X POST "https://api.medivana.cloud/api/v1/oauth/token" \
-u "$MEDIVANA_CLIENT_ID:$MEDIVANA_CLIENT_SECRET" \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'scope=patients:read encounters:read'const basic = Buffer.from(`${process.env.MEDIVANA_CLIENT_ID}:${process.env.MEDIVANA_CLIENT_SECRET}`).toString("base64");
const res = await fetch("https://api.medivana.cloud/api/v1/oauth/token", {
method: "POST",
headers: { Authorization: `Basic ${basic}` },
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "patients:read encounters:read",
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();import os
import requests
resp = requests.post(
"https://api.medivana.cloud/api/v1/oauth/token",
auth=(os.environ["MEDIVANA_CLIENT_ID"], os.environ["MEDIVANA_CLIENT_SECRET"]),
data={
"grant_type": "client_credentials",
"scope": "patients:read encounters:read",
},
timeout=30,
)
resp.raise_for_status()
data = resp.json()2. Search patients with the token
curl -X GET "https://api.medivana.cloud/api/v1/patients?q=RM-000123" \
-H "Authorization: Bearer $MEDIVANA_TOKEN"const res = await fetch("https://api.medivana.cloud/api/v1/patients?q=RM-000123", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.MEDIVANA_TOKEN}`,
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();import os
import requests
resp = requests.get(
"https://api.medivana.cloud/api/v1/patients?q=RM-000123",
headers={"Authorization": f"Bearer {os.environ['MEDIVANA_TOKEN']}"},
timeout=30,
)
resp.raise_for_status()
data = resp.json()3. Fetch the next page
Paginated lists return next_cursor. Send it back as ?cursor= until next_cursor is absent.
curl "https://api.medivana.cloud/api/v1/patients?limit=100&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $MEDIVANA_TOKEN"let cursor;
do {
const url = new URL("https://api.medivana.cloud/api/v1/patients");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.MEDIVANA_TOKEN}` } });
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const page = await res.json();
for (const patient of page.items) console.log(patient.mrn, patient.name);
cursor = page.next_cursor;
} while (cursor);import os
import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['MEDIVANA_TOKEN']}"
params = {"limit": 100}
while True:
resp = session.get("https://api.medivana.cloud/api/v1/patients", params=params, timeout=30)
resp.raise_for_status()
page = resp.json()
for patient in page["items"]:
print(patient["mrn"], patient["name"])
if not page.get("next_cursor"):
break
params["cursor"] = page["next_cursor"]Authorization
Scopes and RBAC permissions
Request as few scopes as possible. Operations the token scope does not allow answer 403 forbidden; context endpoints (branches, units, practitioners, terminology) work with any token.
| Scopes | RBAC permissions | Operations |
|---|---|---|
| patients:read | patient.read | 9 operations |
| patients:write | patient.read, patient.write | 20 operations |
| encounters:read | encounter.read | 62 operations |
| appointments:read | appointment.read | 5 operations |
| appointments:write | appointment.read, appointment.manage | 16 operations |
| invoices:read | invoice.read | 19 operations |
| webhooks:manage | webhook.manage | 9 operations |
Conventions
Rules that apply to every endpoint
Active branch
The X-Branch-ID header selects the branch. Branch-scoped writes reject requests without an active branch (400); single-branch clients use their branch automatically.
Cursor pagination
Lists contain items and next_cursor. Page size is set with limit; cursors are opaque and must not be constructed by hand.
Rate limits
Per client according to rate_limit_per_min (default 120 per minute), and 60 requests per minute per IP on the OAuth endpoints. Exceeding a limit answers 429 rate-limited.
Concurrency
Detail responses carry an ETag with the version. Send If-Match with PATCH so the change is rejected with 412 when someone else changed the data first.
Safe retries
Duplicates are rejected with 409 (same patient NIK, same booking slot), so a request that failed on the network can be retried and then read back.
Versioning and language
The major version is in the path /api/v1. New fields, enum values, events, and endpoints are added without a new version, so ignore unknown fields. Errors follow Accept-Language (id or en).
Errors
RFC 9457 error format
Errors use application/problem+json. Use code and errors[].field in program logic; title and detail are translated according to Accept-Language. Include request_id when contacting support.
| HTTP | code | Meaning |
|---|---|---|
| 400 | bad-request | Malformed request or parameter, or no active branch selected |
| 401 | unauthorized | Token missing, invalid, expired, or revoked |
| 402 | module-locked | The module serving the endpoint is not in the clinic plan |
| 403 | forbidden | The token scopes do not allow this action |
| 403 | confidential-record | The medical record is marked confidential; API clients cannot open it |
| 404 | not-found | The data does not exist or is outside the client's reach |
| 409 | conflict | Duplicate, or the record status does not allow the action |
| 412 | stale | If-Match does not match the latest version |
| 422 | validation | Validation failed; per-field details in errors |
| 429 | rate-limited | Rate limit exceeded; retry in the next minute |
The OAuth endpoints use the RFC 6749 format, for example error invalid_client or invalid_scope.
application/problem+json
{
"type": "https://medivana.cloud/errors/forbidden",
"title": "Akses ditolak",
"status": 403,
"code": "forbidden",
"detail": "Peran Anda tidak memiliki izin untuk aksi ini.",
"instance": "/api/v1/billing/invoices",
"request_id": "01JC9Z3Y8M4N2P5Q7R9S0T1V2W"
}Webhooks
HMAC-SHA256 signed events
Subscribe to events with POST /webhooks. Medivana sends a JSON POST to your https URL with the X-Medivana-Event, X-Medivana-Event-ID, X-Medivana-Delivery, and X-Medivana-Signature headers.
Verify every delivery
- Read the raw body before parsing it, then take t and v1 from X-Medivana-Signature.
- Reject when the difference between now and t exceeds 300 seconds (prevents replay).
- Compute HEX(HMAC-SHA256(secret, t + "." + body)) and compare it with v1 in constant time.
- Deduplicate on the event id; redeliveries carry the same id.
- Answer 2xx within 10 seconds, then process asynchronously.
Retries
Anything other than 2xx, or a timeout, is retried after 1, 5, 15, and 60 minutes, then every 6 hours for 3 days. A 410 Gone answer disables the subscription. Delivery history and manual redelivery are available through the API.
Verification example
import crypto from "node:crypto";
export function verifyMedivanaSignature(rawBody, header, secret, toleranceSec = 300) {
let t = 0;
const sigs = [];
for (const part of header.split(",")) {
const [k, v] = part.trim().split("=", 2);
if (k === "t") t = Number(v);
if (k === "v1") sigs.push(v);
}
if (!t || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => {
const got = Buffer.from(s, "hex");
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
}import hashlib
import hmac
import time
def verify_medivana_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
t, sigs = 0, []
for part in header.split(","):
k, _, v = part.strip().partition("=")
if k == "t" and v.isdigit():
t = int(v)
elif k == "v1":
sigs.append(v)
if not t or not sigs or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)OpenAPI 3.1 specification
Import it into Postman, Insomnia, or a client generator. The specification is tested automatically against the Medivana router and scope map on every change.
Next steps
See Medivana working for your clinic
Try the self-guided demo with sample data, or ask our team to walk you through the flows that matter most to your clinic.
The self-guided demo contains sample data for your clinic type and stays active for 7 days.