Skip to content

Developers

Public API and webhooks

Integrate Medivana securely with your HIS, patient app, or data warehouse. Available on the Klinik Pro and Enterprise plans, or as the API and webhooks add-on on the Klinik plan.

127 operations · 30 webhook events · 7 scopes

Sample token request
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

  1. 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.

  2. 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.

  3. 03

    Call endpoints

    Send Authorization: Bearer ‹token›. Choose a branch with X-Branch-ID when the client covers several branches.

  4. 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

Code example language
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

Code example language
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.

Code example language
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.

Public API scopes and the RBAC permissions they grant
ScopesRBAC permissionsOperations
patients:readpatient.read9 operations
patients:writepatient.read, patient.write20 operations
encounters:readencounter.read62 operations
appointments:readappointment.read5 operations
appointments:writeappointment.read, appointment.manage16 operations
invoices:readinvoice.read19 operations
webhooks:managewebhook.manage9 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.

Public API error codes
HTTPcodeMeaning
400bad-requestMalformed request or parameter, or no active branch selected
401unauthorizedToken missing, invalid, expired, or revoked
402module-lockedThe module serving the endpoint is not in the clinic plan
403forbiddenThe token scopes do not allow this action
403confidential-recordThe medical record is marked confidential; API clients cannot open it
404not-foundThe data does not exist or is outside the client's reach
409conflictDuplicate, or the record status does not allow the action
412staleIf-Match does not match the latest version
422validationValidation failed; per-field details in errors
429rate-limitedRate 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

  1. Read the raw body before parsing it, then take t and v1 from X-Medivana-Signature.
  2. Reject when the difference between now and t exceeds 300 seconds (prevents replay).
  3. Compute HEX(HMAC-SHA256(secret, t + "." + body)) and compare it with v1 in constant time.
  4. Deduplicate on the event id; redeliveries carry the same id.
  5. 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.

See the event catalog

Verification example

Code example language
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.