Back to Integrations

Zilo Reputation API v1

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.


1. Base URL, transport, and CORS

https://<your-zilo-host>/api/v1
  • Every request and response body is 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.
  • Every response — success or error — carries an 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.

2. Authentication

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


3. Rate limits vs. the monthly send cap — two DIFFERENT things

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

4. POST /api/v1/events — the primary path

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.

Request

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

Response — accepted

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 processingprocessed | skipped | dead_letter as the background processor (runs every ~2 minutes) drains it — poll GET /api/v1/events/[id] to watch that transition.

curl

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" }
      }'

4.1 Idempotency

  • 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 body409 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": "..." }
    

5. GET /api/v1/events/[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 metadata only. The patient name/phone/consent you originally sent is never echoed back here (or anywhere in this API) — even after the 90-day retention window purges the stored payload, the envelope above still returns normally.
  • status: receivedprocessingprocessed | 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.
  • An id that doesn't exist, or belongs to a different organization's key, returns 404 in both cases (never a 403 that would confirm the id exists under someone else's account).

6. POST /api/v1/review-requests — the direct-send escape hatch

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

Request

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

Response — a queue row was created

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

Response — a guardrail stopped it (still 200, not an error)

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

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"
      }'

7. GET /api/v1/review-requests/[id]

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.
  • A LIVE row never returns the rendered message body — it contains real patient content and this is a status endpoint. A 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.

8. Test keys

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:

  1. No sender required. You can finish and verify your entire integration before your clinic's WhatsApp/SMS sender is approved.
  2. No side effects on real patients. A test event/request matches an existing patient or contact read-only — it never creates a new growth_external_contacts row. Test traffic never enters the marketing contact registry or acquires a DPDP obligation.
  3. No budget or dedupe consumption. Simulated rows never count against your monthly send cap or the 90-day per-contact dedupe window.
  4. Visibly separated. Simulated rows are excluded from ask analytics and the Reputation Score by default.

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:

  • Supply 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).
  • Say nothing. Zilo records your API key's own DPDP attestation (recorded once, by an org admin, when the key was created) as the consent basis: "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.


10. Error codes — a stable public contract

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.

11. What this API deliberately does not do

  • No /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.
  • No bulk/batch direct-send — that's what CSV upload in the dashboard is for.
  • No cancelling a queued review request.
  • No listing endpoint (GET /api/v1/events or /review-requests) — the dashboard's Integrations Hub is the list view; this API is per-id status only.