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.