Incremental sync
In the iClinic Integration API, patients, appointments, receipts and therapy plans share one way to read changes: you fetch the records that changed since a point in time, one page at a time, using a cursor. For how cursor paging works, see Pagination.
Query parameters
| Parameter | Description |
|---|---|
updatedSincestring · date-timeoptional |
Returns only records whose updatedAt is at or after this time, including a record at exactly this time. Use a full ISO 8601 timestamp with a time zone, for example 2026-10-01T00:00:00.000Z (write the + of a time zone offset as %2B). If you leave it out, the API returns all records. |
cursorstringoptional |
The exact nextCursor value from the previous page. Do not construct or parse it yourself. Use it only with the path that returned it. |
limitintegeroptional |
From 1 to 500. Default 200. |
The response has the form { requestId, <resources>, nextCursor } (for example patients). nextCursor is null on the last page. A parameter that is not in the table is rejected with 400 UNKNOWN_PARAMETER, so a typo in updatedSince cannot silently turn into a full reload.
How to sync
- First run: call without
updatedSinceand follownextCursoruntil it isnull. - For each record: if it has
deletedAt, delete it on your side byid. Otherwise overwrite byid(do not insert a duplicate). - Keep track of the largest
updatedAtyou have seen, and store it only after you have read all the pages. - Later runs: set
updatedSinceto the stored value minus 5 minutes, and keep it the same on every page of that run.
id, makes both cases harmless.
Deleted records (tombstones)
A deleted or merged record stays in the list in a short form, { id, deletedAt, updatedAt }, with no personal data (a patient record also has mergedIntoId). A GET …/{id} request for such a record still returns 200 with the tombstone. A request for an id that does not exist, or that belongs to another clinic, returns the same 404 NOT_FOUND in both cases.
curl -sS "https://iclinic.phongkhamhieploi.vn/api/integration/v1/patients?limit=2&updatedSince=2026-10-01T00:00:00.000Z" \
-H "Authorization: Bearer $ICLINIC_API_KEY"
# → 2 records, "nextCursor": "djF8cGF0aWVudHN8…"
curl -sS "https://iclinic.phongkhamhieploi.vn/api/integration/v1/patients?limit=2&updatedSince=2026-10-01T00:00:00.000Z&cursor=djF8cGF0aWVudHN8…" \
-H "Authorization: Bearer $ICLINIC_API_KEY"
# → 1 record, "nextCursor": null (end of the list)
const BASE = 'https://iclinic.phongkhamhieploi.vn/api/integration/v1';
const OVERLAP_MS = 5 * 60_000;
// Node 18+. `checkpoint` is the value the previous run returned (null on the first run).
export async function syncPatients({ checkpoint, upsert, remove }) {
const since = checkpoint
? new Date(Date.parse(checkpoint) - OVERLAP_MS).toISOString()
: null;
let newest = checkpoint;
let cursor = null;
do {
const url = new URL(`${BASE}/patients`);
url.searchParams.set('limit', '500');
if (since) url.searchParams.set('updatedSince', since); // the same on every page
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.ICLINIC_API_KEY}` },
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
const page = await res.json();
for (const patient of page.patients) {
if (patient.deletedAt) await remove(patient.id); // tombstone
else await upsert(patient); // overwrite by id
if (!newest || patient.updatedAt > newest) newest = patient.updatedAt;
}
cursor = page.nextCursor;
} while (cursor);
return newest; // store as the checkpoint for the next run, only after reading all pages
}
import os
from datetime import datetime, timedelta, timezone
import requests
BASE = "https://iclinic.phongkhamhieploi.vn/api/integration/v1"
OVERLAP = timedelta(minutes=5)
def sync_patients(checkpoint, upsert, remove):
"""checkpoint: the updatedAt string from the previous run (None on the first run)."""
since = None
if checkpoint:
at = datetime.fromisoformat(checkpoint.replace("Z", "+00:00")) - OVERLAP
since = at.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
headers = {"Authorization": f"Bearer {os.environ['ICLINIC_API_KEY']}"}
newest, cursor = checkpoint, None
while True:
params = {"limit": 500}
if since:
params["updatedSince"] = since # the same on every page
if cursor:
params["cursor"] = cursor
res = requests.get(f"{BASE}/patients", headers=headers, params=params, timeout=30)
res.raise_for_status()
page = res.json()
for patient in page["patients"]:
if patient["deletedAt"]:
remove(patient["id"]) # tombstone
else:
upsert(patient) # overwrite by id
if not newest or patient["updatedAt"] > newest:
newest = patient["updatedAt"]
cursor = page["nextCursor"]
if not cursor:
return newest # store as the checkpoint for the next run, only after reading all pages