Status: Stable (R1). Audience: the integration engineer at a hospital's HIS/EMR/CRM vendor (e.g. Careaxes) wiring up Zilo Reputation Engine.
The 15-minute integration. Get an API key from your Zilo contact, POST one event, watch the patient's WhatsApp arrive. That is the whole demo — if you have to ask a question to get here, file it as a bug against this doc.
https://<your-zilo-host>/api/v1
application/json./api/v1 sends no CORS headers, ever. It must be called server-to-server
— never from a browser page. This is deliberate: a leaked key is unusable
from a browser tab if the browser's CORS policy blocks the response anyway.X-Request-Id header and
echoes the same value as request_id in the JSON body. If you send your
own X-Request-Id header on the request, it is echoed back unchanged —
useful for correlating a retry with your own logs.Authorization: Bearer zilo_sk_live_xxxxx
Keys are issued from the Zilo dashboard (Integrations → API Keys, org
admin only) and look like zilo_sk_live_<43 chars> (production) or
zilo_sk_test_<43 chars> (see §7 Test keys).
The plaintext key is shown exactly once, at creation. Zilo never stores it and cannot recover it if lost — rotate (create a new key, revoke the old one) rather than requesting a copy.
Minting a key requires an org admin to record a DPDP consent attestation. That attestation is what backs the "who consented to processing this contact" question when your own request doesn't supply one — see §6 Consent.
Scopes — a key is granted one or more of:
| Scope | Grants |
|---|---|
reputation:events |
POST /api/v1/events, and read access to GET /api/v1/events/[id] |
reputation:send |
POST /api/v1/review-requests, and read access to GET /api/v1/review-requests/[id] |
reputation:read |
Read-only access to both status endpoints (GET /api/v1/events/[id] and GET /api/v1/review-requests/[id]) without needing the corresponding write scope |
reputation:webhooks |
Reserved and still unused — see §11. Webhook subscriptions are managed from the dashboard (Integrations Hub → Webhooks, org admin, Enterprise plan), not this API. |
A missing/invalid/revoked key returns 401 unauthorized. A key that
lacks the required scope for an endpoint returns 403 forbidden_scope.
An org whose plan doesn't include API access returns 403 not_entitled
(Reputation Engine, Pro tier or above).
These are conceptually separate. Do not confuse a 429 with a send_cap_reached
skip — see §5.4.
| Limit | Scope | Window | Response when exceeded |
|---|---|---|---|
POST /api/v1/events |
per API key | 60 requests / minute | 429 |
POST /api/v1/events |
per API key | 5,000 requests / day | 429 |
POST /api/v1/review-requests |
per API key | 30 requests / minute | 429 |
| Monthly send cap (Starter 500 / Pro 2,000 / Enterprise unlimited) | per organization | calendar month | Not a 429 — surfaces as outcome:"skipped", reason:"send_cap_reached" on the affected request/event, and as a dashboard banner |
A 429 response body:
{ "error": { "code": "rate_limited", "message": "Rate limit exceeded." }, "request_id": "..." }
and carries these headers:
Retry-After: 12
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785657660
Requires scope: reputation:events.
Report a business event; Zilo's rules engine owns channel, timing,
throttling, dedupe, consent and suppression. This call returns fast
(202 Accepted, no waiting on downstream sends) — your discharge screen
is never blocked on Zilo.
POST /api/v1/events
Authorization: Bearer zilo_sk_live_xxxxx
Idempotency-Key: his-visit-4471-v1
Content-Type: application/json
{
"event_type": "visit.completed",
"occurred_at": "2026-08-02T10:15:00+05:30",
"source_system": "careaxes",
"patient": {
"name": "Asha Devi",
"phone": "9812345678",
"email": "asha@example.com",
"external_id": "HIS-4471",
"language": "hi"
},
"doctor_ref": { "external_id": "DR-19", "name": "Dr Sharma" },
"location_ref": { "external_id": "BH-01" },
"consent": { "basis": "explicit_optin", "obtained_at": "2026-08-01T09:00:00Z", "source": "his-registration-form" },
"metadata": { "visit_reason": "follow-up" }
}
| Field | Required | Notes |
|---|---|---|
event_type |
yes | One of: appointment.completed, visit.completed, patient.discharged, invoice.paid. Unknown values → 400. |
occurred_at |
yes | ISO-8601 date-time. Must not be more than 5 minutes in the future (clock-skew tolerance, not a business rule). |
source_system |
no | Free text, e.g. your HIS's name — shown in the ingestion log. |
patient |
yes | At least one of phone or email is required. phone is normalised to E.164 (a bare 10-digit Indian mobile defaults to +91). |
doctor_ref.external_id |
no | Matched against the provider your org registered with this same external id in the Integrations Hub. |
location_ref.external_id |
no | Matched against a registered location. If your org has only one location, this is optional — Zilo falls back to it automatically. |
consent |
no | If supplied, basis, obtained_at and source are all required together — a partial object is rejected. If omitted, Zilo records your key's own DPDP attestation as the consent basis (see §6). |
metadata |
no | Free-form JSON, ≤ 4 KB. |
Limits: request body ≤ 32 KB, metadata ≤ 4 KB. Unknown top-level
fields are silently ignored (forward compatible — safe to add new fields on
your side before Zilo documents them).
HTTP/1.1 202 Accepted
X-Request-Id: 7c9e...
{ "event_id": "3f1e...", "status": "received", "duplicate": false, "request_id": "7c9e..." }
status starts at received and moves through processing → processed |
skipped | dead_letter as the background processor (runs every ~2 minutes)
drains it — poll GET /api/v1/events/[id] to watch
that transition.
curl -s -X POST https://<your-zilo-host>/api/v1/events \
-H "Authorization: Bearer zilo_sk_live_xxxxx" \
-H "Idempotency-Key: his-visit-4471-v1" \
-H "Content-Type: application/json" \
-d '{
"event_type": "visit.completed",
"occurred_at": "2026-08-02T10:15:00+05:30",
"patient": { "name": "Asha Devi", "phone": "9812345678", "external_id": "HIS-4471" }
}'
Idempotency-Key is optional. If you omit it, Zilo derives one from
sha256(org_id | event_type | patient_phone | occurred_at) — a naive retry
on timeout (no header, no special handling on your side) still dedupes
correctly. If you send it, use one value per logical event and re-send the
identical value on every retry of that same event.
Same key + same body, sent twice → the second call returns 200 with
duplicate: true and the event's current status. No second event row
is created.
{ "event_id": "3f1e...", "status": "processed", "duplicate": true, "request_id": "..." }
Same key + a DIFFERENT body → 409 idempotency_key_reuse. This is a
caller bug (you reused a key for two different events) — Zilo refuses to
silently overwrite the first one:
{ "error": { "code": "idempotency_key_reuse", "message": "This Idempotency-Key was already used with a different request body." }, "request_id": "..." }
Requires scope: reputation:events or reputation:read.
Poll what became of an event without a support ticket.
curl -s https://<your-zilo-host>/api/v1/events/3f1e... \
-H "Authorization: Bearer zilo_sk_live_xxxxx"
{
"event_id": "3f1e...",
"event_type": "visit.completed",
"status": "processed",
"skip_reason": null,
"error": null,
"attempts": 0,
"resulting_request_id": "9a2b...",
"received_at": "2026-08-02T04:45:05.000Z",
"processed_at": "2026-08-02T04:46:00.000Z",
"request_id": "..."
}
status: received → processing → processed | skipped | dead_letter.skip_reason (when status:"skipped"): a machine code such as no_rule,
opted_out, deduped, send_cap_reached.error (when status:"dead_letter"): a machine code for self-diagnosis
after 3 failed processing attempts.404 in both cases (never a 403 that would confirm the id exists
under someone else's account).Requires scope: reputation:send.
For CRMs that want explicit control over who gets asked and when, instead of
modelling their workflow as an event. This bypasses the rules engine's
TIMING (you decide the moment, not a delay_hours wait) — it never bypasses
the GUARDRAILS (consent, suppression, dedupe, monthly cap all still apply).
POST /api/v1/review-requests
Authorization: Bearer zilo_sk_live_xxxxx
Content-Type: application/json
{
"contact": { "name": "Asha Devi", "phone": "9812345678" },
"event_type": "visit.completed",
"channel": "whatsapp",
"consent": { "basis": "explicit_optin", "obtained_at": "2026-08-01T09:00:00Z", "source": "crm-ui" },
"dedupe_key": "crm-request-42",
"metadata": {}
}
| Field | Required | Notes |
|---|---|---|
contact |
yes | At least one of phone or email. |
event_type |
yes | One of: appointment.completed, visit.completed, consult.completed, patient.discharged, invoice.paid. Selects which of your org's configured ask rules (channel, dedupe window) applies — a direct send is still "an ask for event X", just triggered by you instead of waiting on the event pipeline. |
channel |
yes | One of whatsapp, sms, email. Must match the channel your org has configured for event_type — a mismatch returns 400 naming both, so a wrong event_type doesn't silently send on a channel you didn't ask for. |
consent |
no | Same all-or-nothing group as /events. If omitted, your key's DPDP attestation is used as the consent basis — unless the key genuinely has no attestation on record, in which case this is a 400. |
dedupe_key |
no | If you omit it, Zilo derives one deterministically from (org, contact, event_type, channel) — a network retry of the identical request won't double-message. If you want to send again for the same contact/event_type/channel later (not just retry), supply your own key each time (e.g. include today's date). |
HTTP/1.1 201 Created
{ "review_request_id": "9a2b...", "outcome": "queued", "test_mode": false, "request_id": "..." }
outcome is "queued" or "held" (the org has no live sender yet — the
request is created and will send once one is connected).
HTTP/1.1 200 OK
{ "review_request_id": null, "outcome": "skipped", "reason": "deduped", "request_id": "..." }
reason is a machine code: no_rule, opted_out, deduped,
send_cap_reached. A guardrail hit is a valid, expected outcome — not a
failure of your integration.
curl -s -X POST https://<your-zilo-host>/api/v1/review-requests \
-H "Authorization: Bearer zilo_sk_live_xxxxx" \
-H "Content-Type: application/json" \
-d '{
"contact": { "name": "Asha Devi", "phone": "9812345678" },
"event_type": "visit.completed",
"channel": "whatsapp"
}'
Requires scope: reputation:send or reputation:read.
curl -s https://<your-zilo-host>/api/v1/review-requests/9a2b... \
-H "Authorization: Bearer zilo_sk_live_xxxxx"
{
"review_request_id": "9a2b...",
"status": "sent",
"channel": "whatsapp",
"scheduled_at": "2026-08-02T10:15:00.000Z",
"sent_at": "2026-08-02T10:15:04.000Z",
"clicked_at": null,
"click_count": 0,
"reminder_count": 0,
"skip_reason": null,
"hold_expires_at": null,
"test_mode": false,
"request_id": "..."
}
status still awaiting the processor → "queued" (not an error).status:"held" → hold_expires_at tells you when Zilo will give up waiting
for a live sender.test_mode / simulated
row (see §8) additionally returns rendered_body and
link_target_url so you can verify the whole pipeline before your BSP
approval lands.A zilo_sk_test_... key runs the entire pipeline — auth, scope, rate
limit, validation, idempotency, contact resolution, rule lookup, template
selection, variable binding, tracked-link generation — and then stops.
No message is ever sent; no adapter is ever invoked. The resulting row's
terminal status is "simulated" and GET /api/v1/review-requests/[id]
exposes the fully rendered body + resolved link for that row so you can
verify your integration produced exactly what you expect.
Four rules make this safe:
growth_external_contacts row. Test traffic never enters the marketing
contact registry or acquires a DPDP obligation.Use a test key for exactly the same POST /api/v1/events and
POST /api/v1/review-requests calls shown above — nothing else changes.
Every externally-supplied contact needs a consent basis. You have two options, and you don't have to choose per-request:
consent yourself on the request (basis + obtained_at +
source, all three together). Use this when you capture consent at the
point of collection (e.g. a HIS registration form)."API key attestation: <key name> (<key prefix>), attested by <admin email> on <date>". This is the right default when your integration
itself doesn't collect a separate consent flag per patient.Zilo never creates a patients clinical record from this API — an
external system reporting an event never silently mints a chart. A phone
match against an existing patient links to it (read-only); everything else
becomes (or matches) a marketing contact in growth_external_contacts.
Every error response has this shape:
{ "error": { "code": "...", "message": "...", "field": "..." }, "request_id": "..." }
(field is present only for invalid_request.)
These codes may be added to over time. They are never renamed, and their
HTTP status never changes underneath you — safe to branch your integration
logic on the code string.
| Code | HTTP | Meaning |
|---|---|---|
unauthorized |
401 | Missing, malformed, or revoked API key. |
forbidden_scope |
403 | The key doesn't carry the scope this endpoint requires. |
not_entitled |
403 | The organization's plan doesn't include API access (Reputation Engine, Pro+). |
invalid_request |
400 | The request body failed validation — see field for which one. |
idempotency_key_reuse |
409 | The same Idempotency-Key was reused with a different body. |
rate_limited |
429 | Per-key rate limit exceeded — see Retry-After. |
not_found |
404 | The requested event_id/review_request_id doesn't exist, or doesn't belong to your key's organization. |
internal_error |
500 | Something went wrong on Zilo's side. Retry with the same request (idempotency protects you); contact support with the request_id if it persists. |
/api/v1/webhooks — as of today, still. Outbound webhooks
(review.created, review.negative, survey.completed) exist and are
documented in docs/api/webhooks.md, but subscriptions
to them are managed exclusively from the Zilo dashboard (Integrations
Hub → Webhooks tab, org admin, Enterprise plan) — not from this API. A key
granted the reputation:webhooks scope still cannot create, list, rotate
or delete a webhook subscription; that scope is entitled at Enterprise and
kept defined so the routes can be added later without a contract change,
but no such routes exist right now. This is a deliberate design choice,
not a gap on the roadmap: a key that could register an arbitrary
subscriber URL would be a data-exfiltration primitive, and registering one
requires a human org admin instead. If your integration needs webhooks,
have an org admin set them up in the dashboard and read
docs/api/webhooks.md for the payloads, signature
scheme, and egress behaviour your endpoint should expect.GET /api/v1/events or /review-requests) — the
dashboard's Integrations Hub is the list view; this API is per-id status
only.(ADR-0049. Added 2026-10-24. Sections 1–11 above are unchanged.)
A hospital information system can keep its patients and appointments in step
with Zilo by sending each change here. This is a different surface from
/api/v1/events (§4): that one asks a patient for a review and never creates a
patient; this one creates and updates the clinic's patient and appointment
rows, and only for an org that has an admin-configured HIS sync source.
Scope and plan. The key needs the his:sync scope, and the org needs a
Growth subscription on a Pro-or-higher plan (a CSV upload in the dashboard
needs no key and works on every Growth plan). One key feeds exactly one source:
create the source in Integrations Hub → HIS Sync, choosing this key. A key
with no connected source gets 404 not_found on every call.
Persist-then-202. The body is stored exactly as you sent it and mapped to
patients and appointments within a couple of minutes, through the source's
mapping profile. A 202 therefore means received and authenticated, not
applied; read the status with GET /api/v1/his/events/[id]. A bad mapping is
fixed on Zilo's side and the stored events are re-applied; you do not resend.
POST /api/v1/his/events
Authorization: Bearer zilo_sk_live_…
Idempotency-Key: <your own id for this change> # optional
Content-Type: application/json
{
"event_type": "appointment.upserted",
"occurred_at": "2026-10-24T09:30:00+05:30",
"idempotency_key": "…", // optional; the header wins
"data": { …the record exactly as your system holds it… }
}
event_type is one of patient.upserted, patient.deleted, patient.erased,
appointment.upserted, appointment.cancelled, visit.completed.
data is the vendor-shaped record: Zilo maps it through the source's profile
(the default accepts names such as patientId/uhid, patientName, mobile,
appointmentId, doctorName, appointmentDateTime, status). An appointment
may carry its patient's own fields (patientName, mobile) alongside, which
creates the patient if it does not exist yet.
What is never accepted. A body carrying any of diagnosis,
diagnosis_codes, notes, soap, vitals, prescription, aadhaar,
aadhar, pan or address — at any depth — is rejected with 400 invalid_request naming the field. It is not accepted-and-dropped: remove it at
source.
Idempotency. Send your own key (header or body field) and a retry is safe: a
repeat with the same body returns 200 with duplicate: true; the same key
with a different body is 409 idempotency_key_reuse. Without one, Zilo derives
a key from the event type, occurred_at and a hash of data.
Order. A late scheduled can never undo an earlier completed: an older
status arriving after a newer one is rejected as stale_status. A
visit.completed for a patient Zilo has not seen yet is retried a few times
before being parked.
| Status | Body |
|---|---|
202 |
{ "event_id", "status": "received", "duplicate": false, "request_id" } |
200 |
the same, with "duplicate": true |
400 invalid_request |
{ "error": { "code", "message", "field" } } — size over 32 KB, an unknown event_type, a rejected field, a malformed occurred_at |
401 / 403 / 404 / 409 / 429 |
the shared codes in §10. 404 not_found = no connected source for this key |
{ "events": [ … up to 500 events … ] }, at most 512 KB, each event up to
32 KB and shaped as above. Every event carries its own idempotency key, so a
retried batch re-sends everything and only the new events are stored. The reply
is 202 with { accepted, duplicates, conflicts, results: [ { index, event_id, status, duplicate } | { index, error: "idempotency_key_reuse" } ] } — one
reused key never blocks its neighbours.
{ event_id, event_type, status, reject_reason, received_at, applied_at } with
status one of received, processing, applied, rejected, dead_letter.
reject_reason is a machine code (for example no_provider, invalid_phone,
unmapped_status, stale_status) and never a value from your data. The stored
payload is never returned.
429 rate_limited with Retry-After.Synced patients receive appointment reminders and confirmations. They are
sent marketing campaigns only once marketing consent is recorded — by the
front desk, or carried from your system when an org admin has named the field
that holds it and confirmed it records each patient's own consent. Only a value
of exactly true carries; anything else leaves consent as it was, and Zilo
never clears a consent recorded at the desk.
patient.erased replaces the patient's details in Zilo and removes the stored
copies of what you sent about them. It does not change anything in your own
system.