{"openapi":"3.1.0","info":{"title":"Omnio Suite API","version":"1.0.0","description":"The **Omnio Suite API** lets you send messages across SMS, WhatsApp, Email, Push and more — using templates you build in the dashboard and fill with live data at send time. Multi-tenant, with a BYOK + Managed provider model.\n\n## Quick start\n1. Create an API key in the dashboard under **Developer → API Keys**.\n2. Build a template (**Templates → New**) for the channel you want, with `{{ placeholders }}`.\n3. Send it — `POST /v1/messages` with a `template` + a `variables` bag.\n\n## Authentication — two kinds of keys\nCreate both under **Developer → API Keys**.\n\n- **Secret keys** — `sandesh_live_…`. Keys labelled `sandesh_test_…` currently behave exactly like live keys — messages are delivered and billed. Server-to-server only. Sent as a Bearer token; **full** access to the REST API (send, read, manage). Keep them on your backend.\n\n  ```\n  Authorization: Bearer sandesh_live_xxxxxxxx\n  ```\n\n- **Publishable keys** — `sandesh_pub_…`. **Safe to embed** in a website or app. Passed as a **`writeKey`** in the body/query of the public SDK endpoints (`/v1/sdk/*`). They can register push devices and ingest events, but **cannot send messages or read data** — so it's fine if they show up in page source. They are rejected on the Bearer REST API (`403`).\n\n> Rule of thumb: anything that runs in a browser or mobile app → **publishable**. Anything on your server → **secret**.","contact":{"name":"Omnio Suite Support","url":"https://omniosuite.com/docs","email":"support@omniosuite.com"},"license":{"name":"Proprietary","url":"https://omniosuite.com/terms"},"termsOfService":"https://omniosuite.com/terms"},"servers":[{"url":"https://omniosuite.com/api/v1"}],"tags":[{"name":"Audiences"},{"name":"Contacts"},{"name":"Messages","description":"Send a message by referencing a **template** you created in the dashboard and filling its variables. The template is the reusable shape (with `{{ placeholders }}`); the live data — order id, OTP, subject — travels in each API call inside `variables`. Sending always uses a **secret** key (Bearer).\n\n`{{ contact.name }}`, `{{ contact.email }}`, `{{ trait.<key> }}`, `{{ tenant.name }}` auto-fill from the stored contact — you don't resend them. `variables` accepts a named object `{\"subject\":\"…\"}` (recommended) or a positional form (array / `{\"1\":…}` / `{\"var_1\":…}`).\n\nSending **WhatsApp**? It has its own endpoint — `POST /v1/whatsapp/messages` — with media headers, button values and carousels. See the WhatsApp section.\n\n### When a send is refused for billing\n\nA send blocked because of money returns `422` with its **own** `error.code` instead of the generic `send_refused`, and the code is the same on every send endpoint (`/v1/messages`, `/v1/messages` bulk, `/v1/notify/*`, `/v1/calls`, `/v1/whatsapp/messages`) — so \"am I out of funds?\" is one check, not five string matches. Nothing was sent and nothing was charged; these are the only refusals that a payment fixes, so they are the ones worth retrying.\n\n| `error.code` | What happened | What clears it |\n| --- | --- | --- |\n| `insufficient_funds` | Prepaid wallet cannot cover the send | Top up, then send again |\n| `credit_limit` | Postpaid: this send would pass your credit limit | Settle the open invoice, then send again |\n| `account_suspended` | The account is suspended | Settle the outstanding balance to reactivate |\n\nIn a bulk send these appear per recipient in `errors[]`, carrying the same `code` alongside that recipient's `index`."},{"name":"Email","description":"`POST /v1/emails` sends a **raw email** — you pass the subject and body directly, with **no Templates entry**. Use it for one-off or fully dynamic mail where a stored template would only get in the way; for repeatable, versioned content send a template through `/v1/messages` instead.\n\nDeveloper-surface API keys only (engage keys get `403 engage_key_forbidden`). The send is **stateless** — no contact is stored, only a delivery log — but the email suppression list and pre-send address validation still apply.\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/emails \\\n  -H \"Authorization: Bearer sandesh_live_…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"to\": \"user@example.com\",\n    \"subject\": \"Your invoice is ready\",\n    \"html\": \"<p>Hi — your invoice is attached.</p>\",\n    \"text\": \"Hi — your invoice is attached.\",\n    \"cc\": [\"accounts@acme.com\", \"ops@acme.com\"],\n    \"bcc\": [\"audit@acme.com\"],\n    \"reply_to\": \"support@acme.com\",\n    \"attachments\": [\n      {\n        \"filename\": \"invoice.pdf\",\n        \"content\": \"JVBERi0xLjQKJ…\",\n        \"content_type\": \"application/pdf\"\n      }\n    ]\n  }'\n```\n\n**Fields**\n\n- `to` — the primary recipient (one address). Use `cc` / `bcc` for more.\n- `subject` — required.\n- `html` and/or `text` — the body; **at least one is required**. With both, the email is multipart (HTML with a plain-text fallback).\n- `cc`, `bcc` — arrays of addresses (**each up to 50** recipients). Delivered on every email provider (SES, SMTP, SendGrid, Postmark, Mailgun, Brevo, Resend, SparkPost, Zepto, Oracle, Azure).\n- `reply_to` — where replies go, if different from `from`.\n- `attachments` — an array of files (**up to 10**, **10 MB combined** once decoded). Each item is `{ \"filename\": \"…\", \"content\": \"<base64>\", \"content_type\": \"…\" }`:\n  - `filename` — **required**; the name shown on the attachment (any path is stripped).\n  - `content` — **required**; the file's bytes **base64-encoded**. A `data:<mime>;base64,…` data URI is also accepted and unwrapped.\n  - `content_type` — optional MIME type (e.g. `application/pdf`). Omit it and the provider infers it from the filename; a data URI's own type is adopted.\n  - **Attachments are sent only through providers that support them — currently the managed pool / Amazon SES and SMTP (IPWarmup).** Routing to any other provider (`sendgrid`, `postmark`, `mailgun`, `brevo`, `resend`, …) with attachments **fails the send** (`failure_code: attachments_unsupported`) rather than quietly mailing without the files — read it on the delivery log / `GET /v1/messages/{id}`, or synchronously with `wait: true`. So either omit `provider` (use the managed pool) or route to `ses` / an SMTP credential.\n  - Validation is at request time: over the count or combined-size limit → `422` on `attachments`; `content` that is not valid base64 → `422` on `attachments.N.content`.\n- `from`, `from_name` — the verified address to send **as** + its inbox display name. **Omit `from` to use the default sender configured in your panel** — on the managed pool (`provider: \"omnio\"`) that is your default verified sender identity (Email setup); on a BYOK provider it is the address on your connected credential. On the managed pool a sender-scoped key must stay within its allow-list (`422 sender_not_allowed`), and an unverified managed sender is refused (`email_identity_unverified`).\n- `provider` / `provider_chain` — which provider to send through. **Omit to use your default email provider**; pass `\"omnio\"` for the managed pool, a connected slug (`ses`, `sendgrid`, `postmark`, `mailgun`, `brevo`, `resend`, …), or an ordered `provider_chain` for strict failover.\n- `idempotency_key` — a retry with the same key replays the original send instead of mailing twice.\n- `correlation_id` — your own cross-system reference, echoed back on the delivery log + webhooks.\n\n**Response.** `201` with the delivery log (`data.id`, `data.status`, `data.recipient`). A send refused for **billing** returns its own `error.code` (see *When a send is refused for billing* in the Messages section); anything else is `422 send_refused` with the reason."},{"name":"Events"},{"name":"In-app inbox"},{"name":"Notifications","description":"`POST /v1/notify` is the unified single-send where **you choose the provider**. Resolve a recipient (`to`), name a `notification` (template slug) + `data` (merge values), and pass `provider` (a single provider slug) — or `provider_chain` (an ordered list = a **strict** fallback chain: exactly those providers, in order, nothing else appended). If none of the named providers resolve to a permitted, active credential the call is refused (`422 send_refused / provider_not_available`) — there is **no silent fallback** to default routing.\n\nDeveloper-surface API keys only (engage keys get `403 engage_key_forbidden`). Consent + quiet-hours are respected; `skip_consent` is honoured only for transactional/service/otp templates. **DLT (India SMS) is always enforced** — there is no field to skip it. Reusing an `idempotency_key` replays the original send (it does not re-route to a new provider).\n\n**Email only** — `POST /v1/notify/email` accepts two extra fields:\n\n> The verified-identity behaviour of `from` applies to the **managed pool** (`provider: \"omnio\"`), which only works when **Omnio Suite is turned on for email** in your workspace (Email setup) and you have at least one verified sender identity. Without it, send through your own connected (BYOK) provider instead.\n\n- `from` — the verified sender address to send **as**. On the managed pool (`provider: \"omnio\"`) it must be one of your verified sender identities (a verified single sender, or any address on a verified domain) — and, if the key is sender-scoped, within its allow-list; otherwise the send is refused (`422 sender_not_allowed`). Omit it to use your default verified sender. For a **BYOK** provider the address is fixed by your connected credential, so `from` is ignored.\n- `from_name` — the inbox **display name** (max 100 chars), e.g. `\"Acme Team\" <hello@acme.com>`. Omit it to use the sender's saved default name. Honoured for both managed and BYOK."},{"name":"Meta"},{"name":"Push notifications","description":"Push is token-addressed: a device gets a token (FCM / APNs / browser), you register it, and Omnio Suite fans each notification out to all of a contact's devices — iOS→APNs, Android→FCM, Web→FCM/WebPush.\n\n### Easiest: the drop-in web widget (one line)\nFor a website you write **no** Firebase or OneSignal code. Create a **publishable** key, then paste:\n\n```html\n<script src=\"https://omniosuite.com/omnio-push/sandesh_pub_xxx.js\"\n        data-user-id=\"CURRENT_USER_ID\"></script>\n```\n\nThe widget is **provider-aware**: it asks `GET /v1/sdk/config` which provider your tenant routes to and runs that flow automatically (FCM or OneSignal) — same embed either way. You host **one** service worker at your site root, for the provider you use:\n- **FCM** → `omnio-push-sw.js` (download from **Developer → Push setup**; one generic file, no edits).\n- **OneSignal** → `OneSignalSDKWorker.js` (one line: `importScripts(\"https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.sw.js\");`).\n\nIt shows the opt-in prompt (style/colours/bell from **Developer → Push setup**, served via `GET /v1/sdk/widget`), captures the token/subscription, and registers the visitor with rich context (locale, timezone, device, referrer) — the server adds IP + geo. `data-user-id` is **optional**: drop it for anonymous visitors.\n\n### The endpoints (`/v1/sdk/*`, publishable key)\nFor browsers/apps — you send your own `userId` (or stay anonymous); no contact id needed, the contact is created/linked for you.\n| | |\n|---|---|\n| `POST /v1/sdk/devices` | register a device |\n| `POST /v1/sdk/devices/unsubscribe` | opt a visitor out (the widget's bell) |\n| `GET  /v1/sdk/config` | which Firebase/APNs project to build against |\n| `GET  /v1/sdk/widget` | the opt-in prompt's look (set in the dashboard) |\n\n### Binding a subscriber to your user\nA publishable key is public, so a `userId` it sends is, by default, **accepted as-is** (simplest — like OneSignal's default). If you push sensitive content (OTPs, account alerts), turn on **identity verification** in **Developer → Push setup**: your backend then signs the id and the page passes the hash, so nobody can spoof a user.\n\n```php\n// On YOUR server — secret never reaches the browser:\n$hash = hash_hmac('sha256', $userId, $YOUR_PUSH_IDENTITY_SECRET);\n```\n```html\n<script src=\"https://omniosuite.com/omnio-push/sandesh_pub_xxx.js\"\n        data-user-id=\"<?= $userId ?>\" data-identity-hash=\"<?= $hash ?>\"></script>\n```\n\nWithout a valid hash (when verification is on) the `userId` is ignored and the visitor stays anonymous. You can also bind a user entirely from your **backend** with a secret key — no hash needed there.\n\n### BYOK or Managed?\nThe integration is **identical** — only what `GET /v1/sdk/config` returns differs.\n\n- **Managed** — you own no push project; you ride on ours. `/v1/sdk/config` returns the **platform's** config. Web = zero setup; native = build with the platform's `google-services.json` (we provide it). Billed per notification.\n- **BYOK** — bring your own FCM / APNs / Web-Push on **Providers → Catalog**. Your `service_account_json` (to send) is required; your web config is optional.\n\n> Tokens are **project-bound** — an FCM token only delivers from the project it was minted against. `/v1/sdk/config` returns a `provider_credential_id` you echo back on register to pin the device to that project.\n\n### Using OneSignal (or another aggregator)\nOneSignal is an **aggregator** — it manages the device push, so the device \"token\" is a OneSignal **subscription_id** (not a raw FCM/APNs token) and you need no Firebase project. Connect it on **Providers → Catalog** (App ID + REST API key) and **Make default** so push routes through it. `GET /v1/sdk/config` then returns `{ \"provider\": \"onesignal\", \"appId\": \"…\", \"provider_credential_id\": \"…\" }` (no `firebase` / `vapidPublicKey`).\n\nThe drop-in widget handles this automatically. To do it manually, load the OneSignal Web SDK and register the subscription id:\n\n```js\n// appId comes from GET /v1/sdk/config (provider === \"onesignal\")\nawait OneSignal.init({ appId: cfg.appId });\nawait OneSignal.User.PushSubscription.optIn();\nawait OneSignal.Notifications.requestPermission();\nconst subscriptionId = OneSignal.User.PushSubscription.id;   // ← this is the \"token\"\n\nawait fetch(\"https://omniosuite.com/api/v1/sdk/devices\", {\n  method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({\n    writeKey: \"sandesh_pub_xxx\", userId: currentUser.id, platform: \"android\",\n    token: subscriptionId, provider_slug: \"onesignal\",\n    provider_credential_id: cfg.provider_credential_id\n  })\n});\n```\n\n> Send the subscription_id as `token`. The `provider_credential_id` (from `/v1/sdk/config`) **pins the device to the OneSignal credential**, so it keeps routing through OneSignal even if you later add another default push provider — that pin, not `provider_slug` (a stored label), is what locks the route. Everything downstream — `POST /v1/messages`, templates, variables — is identical to FCM.\n\n### Manual register (FCM, without the widget)\n```js\nimport { initializeApp } from \"firebase/app\";\nimport { getMessaging, getToken } from \"firebase/messaging\";\n\n// Fetch the project your app must use (yours for BYOK, ours for Managed):\nconst cfg = (await fetch(\n  \"https://omniosuite.com/api/v1/sdk/config?writeKey=sandesh_pub_xxx&platform=android\"\n).then(r => r.json())).data;\n\nconst app = initializeApp(cfg.firebase);\nawait Notification.requestPermission();\nconst token = await getToken(getMessaging(app), { vapidKey: cfg.vapidPublicKey });\n\nawait fetch(\"https://omniosuite.com/api/v1/sdk/devices\", {\n  method: \"POST\", headers: { \"Content-Type\": \"application/json\" },\n  body: JSON.stringify({\n    writeKey: \"sandesh_pub_xxx\", userId: currentUser.id,           // optional\n    anonymousId: \"anon_abc\", platform: \"android\",\n    token, provider_credential_id: cfg.provider_credential_id     // pins the project\n    // identityHash: \"<hmac>\"   // only when identity verification is on\n  })\n});\n```\n\n**Android** — `FirebaseMessaging.getInstance().token` → `POST /v1/sdk/devices`.\n**iOS** — `MessagingDelegate.didReceiveRegistrationToken` → `POST /v1/sdk/devices`.\n\nTo opt a visitor out, `POST /v1/sdk/devices/unsubscribe` with the `writeKey` + the `token` (or web-push `endpoint`).\n\n### Send — one variable bag fills the template\nThe push **structure** (title, body, deep link, image, custom data, buttons) lives in the **template**, each field with `{{ }}` placeholders. You send only one `variables` bag:\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/messages \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" -H \"Content-Type: application/json\" \\\n  -d '{ \"to\": { \"external_id\": \"u_42\" }, \"template\": \"order.shipped\",\n        \"force_channel\": \"push\", \"variables\": { \"product\": \"iPhone 15\", \"count\": 3 } }'\n```\n\nNote: sending uses a **secret** key (server-side). The publishable key is only for the SDK endpoints above."},{"name":"Templates","description":"Templates are the versioned, per-channel content that `POST /v1/messages` fills with `variables` at send time. `GET /v1/templates` lists them with each one's variable schema; `GET /v1/templates/{slug}` shows every version.\n\n**Templates-as-code.** `POST /v1/templates` creates a template from your own code — no dashboard step — for **email, sms, push, voice and inapp**. It is **idempotent by `slug`**: the first call creates the template and version 1 (`201`); re-POSTing the same `slug` (same channel + locale) adds the next version and makes it current (`200`), so a deploy can ship updated copy the same way it ships code. POSTing a different `channel` under an existing slug adds that channel to the template. Developer-surface keys only.\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/templates   -H \"Authorization: Bearer sandesh_live_…\"   -H \"Content-Type: application/json\"   -d '{\n    \"slug\": \"course-login-delivered\",\n    \"display_name\": \"Course login delivered\",\n    \"category\": \"transactional\",\n    \"channel\": \"email\",\n    \"locale\": \"en\",\n    \"subject\": \"Your {{ course_name }} login\",\n    \"body\": \"<p>Hi {{ customer_name }}, your login details are on their way.</p>\"\n  }'\n```\n\nPer channel:\n- **email** — `subject` + `body` (HTML or text).\n- **sms** — `body`. DLT placeholders (`{#var#}`, and the typed `{#alp#}` / `{#num#}` / `{#url#}` …) are converted to `{{ var1 }}`, `{{ var2 }}` … in order, so paste the DLT-approved text verbatim. Replacing the current SMS body invalidates the old DLT registration; re-register the new text before sending.\n- **push** — `subject` is the title, `body` the message; at least one is required. Optional `media_url` for a rich image.\n- **voice** — `content` is the IVR flow graph (`{\"start\": …, \"voice\": …, \"language\": …, \"nodes\": {…}}`, the same JSON the flow editor's Raw JSON tab shows). It is validated before saving — a dangling edge or empty step is a `422` listing the path.\n- **inapp** — `subject` + `body`.\n- **whatsapp** — not here: a WhatsApp template has to be submitted to Meta, so use `POST /v1/whatsapp/templates`.\n\nA created template is `active` (sendable) by default; pass `\"status\": \"draft\"` to hold it. `category` is one of `marketing`, `utility`, `transactional`, `service`, `otp`, `authentication`; `display_name` is required only on create."},{"name":"Voice"},{"name":"Wallet"},{"name":"Webhooks","description":"Omnio calls **your** HTTPS endpoint whenever something happens to a message or a call. Add an endpoint under Build → Webhooks or with `POST /v1/webhooks/endpoints` (`url`, `channels` — any of `sms`, `whatsapp`, `email`, `push`, `inapp`, `voice`), and keep the `signing_secret` it returns: it is shown once.\n\n> **Not `/v1/webhooks/sources`.** Sources are Webhooks-as-a-Service — hosted URLs that *your* customers' systems post into and Omnio relays onward. They never receive Omnio's own message or call events. If you created a source expecting delivery receipts, delete it and create an endpoint.\n\n## The envelope\n\n```json\n{\n  \"id\": \"6f1c…\",                       // unique per delivery; retries reuse it — dedupe on this\n  \"event\": \"omnio.call.completed\",\n  \"created\": 1758542400,               // unix seconds\n  \"data\": { … }                        // event-specific, below\n}\n```\n\nRetries: a non-2xx or a timeout is retried with backoff for up to 24 hours. Answer `200` fast and do the work afterwards.\n\n## Verify the signature — every time\n\nTwo headers arrive: `X-Omnio-Timestamp` (unix seconds) and `X-Omnio-Signature: t=<timestamp>,v1=<hex>`. Compute `HMAC-SHA256(secret, \"<timestamp>.<raw body>\")` and compare it to `v1` in constant time; reject if the timestamp is more than 5 minutes old (replay window).\n\n```php\n[$t, $v1] = [null, null];\nforeach (explode(',', $_SERVER['HTTP_X_OMNIO_SIGNATURE']) as $part) { [$k, $v] = explode('=', $part, 2); if ($k === 't') $t = $v; if ($k === 'v1') $v1 = $v; }\n$raw = file_get_contents('php://input');\n$ok = hash_equals(hash_hmac('sha256', \"{$t}.{$raw}\", $secret), (string) $v1) && abs(time() - (int) $t) <= 300;\n```\n\n## Message events — `omnio.message.*`\n\nSent for SMS, WhatsApp, email, push and in-app: `queued`, `sent`, `delivered`, `read`, `failed`, `bounced`, `complained` (email). `data` carries `message_id`, `delivery_log_id`, `contact_id`, `channel_slug`, `provider_slug` (`\"managed\"` when the message went through Omnio's own gateway; your provider's slug when it went through a provider you connected), `provider_msg_id` on `sent`, and on failure `failure_code` + `failure_reason` plus `error` — `{\"code\", \"message\", \"retryable\"}` in Omnio's own vocabulary (`invalid_recipient`, `recipient_unreachable`, `opted_out`, `template_rejected`, `sender_not_approved`, `blocked_by_policy`, `rate_limited`, `carrier_timeout`, `carrier_error`, `service_unavailable`, …). On a managed send `failure_code` / `failure_reason` carry that same code and message; on a provider you connected they are the provider's own, as before. WhatsApp `delivered`, `read` and `failed` events also carry `whatsapp` — Meta's full receipt as Meta sent it (`id` (wamid), `status`, `timestamp`, `recipient_id`, `recipient_user_id`, `pricing`, `conversation`, `errors`), plus `waba_id`, `phone_number_id`, `display_phone_number` and `contacts`; and every WhatsApp event carries `content` — `{\"to\", \"body\", \"variables\"}`, what was actually sent. A message a **chatbot flow** sent also carries `session_id` — the id the chatbot trigger (`POST /v1/whatsapp/chatbots/{id}/trigger`) returned — so you can tie every message in a run back to that trigger. `omnio.message.inbound` carries the same `whatsapp` block for the customer's message (Meta's message object verbatim — text, media ids, button/list replies, location, `context`, `referral` — plus the sender's profile), and — when the message is a Flow submission — the `flow_token` the flow was launched with, so you can match it to the send that returned that token. Every other key stays exactly as described above. Voice does **not** use these — a call reports through `omnio.call.*` only, so you never get two stories for one call.\n\n## Raw WhatsApp events — `omnio.whatsapp.event`\n\nEvery WhatsApp webhook we receive from the provider — Meta (managed or your own number) and Flaxxa WAPI alike — is **also** forwarded to you as-is, on top of the modelled `omnio.message.*` events. One event fires per webhook the provider sends, so a `sent`, a `delivered` and a `read` arrive as three separate events — exactly as Meta delivers them — with the provider's own envelope untouched under `raw`. Use it when you want the complete, unfiltered stream: statuses, inbound messages, template-status changes, quality/account updates, calls — everything, A-to-Z.\n\n`data` carries `channel_slug` (`whatsapp`), `provider_slug` (the real carrier — `meta_cloud` or `flaxxa_wapi`, **not** masked), `received_at`, the provider's full `raw` payload, and — when the event is a receipt for a message one of your chatbot flows sent — the `session_id` that flow's trigger returned (so even a free-form flow message, which never appears in the delivery log, still ties back to its run). Any webhook subscribed to the **WhatsApp** channel receives these automatically.\n\nWhen the event is an **inbound media message** (image, video, audio, document, sticker), the `raw` envelope names only a Meta media id — which you cannot download, because fetching it needs our WhatsApp access token. So the event also carries a `media` block — `{\"id\", \"mime\", \"filename\", \"url\"}` — whose `url` is a signed, expiring link you can GET directly to fetch the file: the signature authorises it (no API key), and we stream the bytes for you. The link is valid for 30 days.\n\n## Call events — `omnio.call.*`\n\n`created` fires first — the moment Omnio records the call, before it rings — so you know a call started (and, for a re-dial, which call it retries) right away. Then one event per state change: `ringing`, `answered`, then exactly one terminal event — `completed`, `no_answer`, `busy`, `failed` or `canceled` — followed by `billed` (the charge) and, if the flow recorded audio, `recording` (a signed, expiring link).\n\nEvery call event's `data` has `call_id`, `direction`, `from`, `to`, `status`, `hangup_cause`, `answered_by` (`human` / `machine` / null), `duration_seconds` (billable), `has_recording`, `campaign_id`, `correlation_id`, `template` (slug + name) and the timeline `queued_at / ringing_at / answered_at / ended_at`.\n\n**A re-dial carries where it sits in the retry chain.** When a call was placed by a retry (an API call re-dialled per its template's calling config), every event for that call — `completed`, `billed`, `recording`, all of them — also carries a `retry` block tying the new call back to the ones before it:\n\n```json\n\"retry\": {\n  \"attempt\": 1,                              // 1-based retry number\n  \"parent_call_id\": \"6f1c…\",                 // the call this attempt re-dials\n  \"parent_call_uuid\": \"0c2e…\",               // (the original call for attempt 1)\n  \"root_call_id\": \"6f1c…\",                   // the original call the chain descends from\n  \"root_call_uuid\": \"0c2e…\"\n}\n```\n\n`parent_call_id` / `root_call_id` are the same `call_id` the parent's and original's own events carried, so you can thread the whole chain together — `root_call_id` groups every attempt, `parent_call_id` points one step back. A first attempt has **no** `retry` block, so its presence alone tells you a call is a re-dial.\n\n**The terminal event carries what happened on the call.** `data.trail` is the ordered list of steps — this is how you act on a keypress:\n\n```json\n\"trail\": [\n  { \"sequence\": 1, \"type\": \"node_entered\", \"node_id\": \"welcome\",   \"label\": \"Welcome\",   \"text\": \"Namaste …\", \"occurred_at\": \"…\" },\n  { \"sequence\": 2, \"type\": \"node_entered\", \"node_id\": \"main_menu\", \"label\": \"Main menu\", \"text\": \"Press 1 for …\", \"occurred_at\": \"…\" },\n  { \"sequence\": 3, \"type\": \"dtmf\",         \"node_id\": \"main_menu\", \"detail\": { \"digits\": \"1\", \"outcome\": \"matched\" }, \"occurred_at\": \"…\" },\n  { \"sequence\": 4, \"type\": \"node_entered\", \"node_id\": \"send_link\", \"label\": \"Send the link\", \"occurred_at\": \"…\" }\n]\n```\n\n- `type: dtmf` — the caller pressed `detail.digits` while on `node_id`; `outcome` is `matched` (a menu choice), `confirmed` (a captured value read back and accepted), `global` (a global shortcut key), `invalid` or `timeout` (`digits` is null).\n- `type: node_entered` — the step the flow moved to (`detail.type` is the step kind; `content` carries the line it was configured to say, variables filled in).\n- `type: message_queued` — a `send` step handed a WhatsApp/SMS to the queue (`detail.channel`, `detail.template`).\n- `type: http_called` / `http_backgrounded` / `http_error` / `http_blocked` — an `http` step ran (status and timing; never your response body).\n\nValues a `capture` step collected are not trail items — they are on `data.variables` by the variable name you chose.\n\n`data.conversation` is the same trail flattened to question/answer pairs for anyone who wants the gist without walking the steps.\n\nSo \"press 1 to get the link on WhatsApp\" is: on `omnio.call.completed`, find the `dtmf` entry for your menu node, and if `digits` is `\"1\"`, send the WhatsApp message from your side (or let the flow's own `send` node do it — then you need no webhook at all).\n\nKeypresses are delivered **at the end of the call**, not live. For real-time reactions during a call, use an `http` node in the flow — it calls your URL the moment the step runs and can branch on your response."},{"name":"WhatsApp","description":"WhatsApp has its **own API section**, separate from `/v1/messages`.\n\nThat is deliberate. `/v1/messages` is the portable envelope — the same body reaches SMS, Email and Push — and WhatsApp needs structure no other channel has: media headers, per-button runtime values, carousel cards, session messages. Folding that in would make every other channel's payload pay for a channel it does not have. Underneath it is the same pipeline: routing, entitlement, consent, the rate governor, metering, the delivery log and your inbox all behave identically.\n\nYour number is connected **once** for the project and shared with the Engage surface — you never reconnect it to use the API, and every template Meta has approved is sendable here with no re-approval.\n\n```\nPOST /v1/whatsapp/messages     send a template or a session message\nPOST /v1/whatsapp/media        upload an image / video / document (multipart)\n```\n\nEvery example below is copy-paste runnable. Use a **secret** key (`sandesh_live_…`); WhatsApp endpoints need a **developer-surface** key (an engage key gets `403 engage_key_forbidden`).\n\nThe body follows **Meta's own idiom**: a `type` discriminator plus a sibling object named after that type. Template and session messages are therefore structurally exclusive — there is no \"which one wins\" question.\n\n---\n\n## 1. The simplest send\n\nTemplate with body placeholders only. Nothing else needed.\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/whatsapp/messages \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"to\": \"+919876543210\",\n    \"type\": \"template\",\n    \"template\": {\n      \"name\": \"order-shipped\",\n      \"variables\": { \"name\": \"Ravi\", \"order_id\": \"SO-2231\" }\n    }\n  }'\n```\n\n`{{ contact.name }}`, `{{ trait.<key> }}` and `{{ tenant.name }}` auto-fill from the stored contact — you only send the live values.\n\n---\n\n## 2. Numbered templates — Meta's `{{1}}`, `{{2}}`\n\nMeta templates use either **numbered** placeholders or **named** ones. Both go in the same `variables` field — a numbered template is just a template whose variables happen to be called \"1\" and \"2\".\n\nThe same applies when you **create** one with `POST /v1/whatsapp/templates`: write the body with `{{1}}`, `{{2}}` and give `samples` either as an object keyed by number (`{\"1\": \"Priya\", \"2\": \"Pro\"}`) or as a plain list in placeholder order (`[\"Priya\", \"Pro\"]`). Omnio submits it to Meta in the positional dialect (`parameter_format: POSITIONAL`, `example.body_text`). A body with named placeholders (`{{ name }}`) is submitted as NAMED. Don't mix the two in one template — Meta refuses that.\n\n**Ordered form** — the Nth value fills the Nth placeholder:\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/whatsapp/messages \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"to\": \"+919876543210\",\n    \"type\": \"template\",\n    \"template\": { \"name\": \"order-shipped\", \"variables\": [\"Ravi\", \"SO-2231\"] }\n  }'\n```\n\n**Keyed form** — identical result, order-independent:\n\n```jsonc\n\"variables\": { \"1\": \"Ravi\", \"2\": \"SO-2231\" }\n```\n\n**What is the order?** First appearance across the template: body, then buttons, then header / carousel. So for\n\n```\nbody:   \"Hi {{1}}, your order {{2}} shipped\"\nbutton: \"https://shop.com/{{3}}\"\n```\n\n`[\"Ravi\", \"SO-2231\", \"trk-99\"]` puts `trk-99` in the button. Use `GET /v1/templates/{slug}` if you want the resolved list rather than counting by eye.\n\n> Numbered placeholders are sent to Meta **without** `parameter_name`, named ones **with** it. Getting that backwards is `(#100) Parameter name is missing or empty`. You never have to think about it — the decision comes from the approved template, not from the shape you sent, so you can mix `{\"1\": \"Ravi\", \"invoice_url\": \"https://…\"}` freely.\n\n---\n\n## 3. Images and files\n\n### Step 1 — upload once\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/whatsapp/media \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" \\\n  -F \"file=@/path/to/hero.jpg\"\n```\n```json\n{ \"data\": { \"id\": \"1602186516975000\", \"mime\": \"image/jpeg\", \"filename\": \"hero.jpg\", \"expires_in_days\": 30 } }\n```\n\n### Step 2 — send it, as many times as you like\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/whatsapp/messages \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"to\": \"+919876543210\",\n    \"type\": \"template\",\n    \"template\": {\n      \"name\": \"order-shipped\",\n      \"variables\": { \"name\": \"Ravi\" },\n      \"header\": { \"media\": { \"id\": \"1602186516975000\" } }\n    }\n  }'\n```\n\n**Why two calls instead of attaching the file?** Because a send fans out. One campaign is 50,000 sends; the file should travel once and the sends should carry its name. Concretely:\n\n- **`{\"id\": \"…\"}`** — Meta already holds the asset and keeps it ~30 days. Fastest, and what Meta itself recommends.\n- **`{\"url\": \"https://…\"}`** — Meta re-fetches the URL on **every single send**, and it must be publicly reachable (no auth, no localhost).\n\nPass exactly one of `url` or `id` — both together is rejected, neither is rejected.\n\n```jsonc\n\"header\": { \"media\": { \"url\": \"https://cdn.example.com/hero.jpg\" } }          // link\n\"header\": { \"media\": { \"id\": \"1602186516975000\" } }                           // handle\n\"header\": { \"media\": { \"id\": \"…\", \"filename\": \"invoice.pdf\" } }               // document header\n```\n\n> A media header's URL stored on the template is Meta's own signed CDN link and **expires** — always supply a fresh `url` or `id` at send time.\n>\n> Handles expire after 30 days. Never store one on a saved campaign — store your own asset and mint a handle at send time.\n\n---\n\n## 4. Buttons — when do you send them?\n\n**Only when a button needs a per-send value the template cannot supply.** Most sends need no `buttons` array at all.\n\n| Button in your template | Send a `buttons` entry? |\n|---|---|\n| URL containing `{{order_id}}` | **No** — it fills from `variables` automatically |\n| URL with no placeholder | **No** — nothing to fill |\n| Quick reply | **Optional** — only to set the payload your webhook receives |\n| Copy code | **Yes** — without `coupon_code` the button has no code |\n| Flow | **Optional** — Omnio mints a `flow_token` and returns it on the response if you don't pass one; supply your own only to correlate with an id from your side |\n\nSo this template — body `Hi {{name}}`, button `https://shop.com/{{order_id}}` — needs nothing extra:\n\n```jsonc\n\"template\": { \"name\": \"order-shipped\", \"variables\": { \"name\": \"Ravi\", \"order_id\": \"SO-2231\" } }\n```\n\nWhen you do need button values, list **only** the buttons that need one, addressed by `ref` — the button's **label** in the approved template, case-insensitive:\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/whatsapp/messages \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"to\": \"+919876543210\",\n    \"type\": \"template\",\n    \"template\": {\n      \"name\": \"order-shipped\",\n      \"variables\": { \"name\": \"Ravi\" },\n      \"buttons\": [\n        { \"ref\": \"Track order\",     \"url_suffix\": \"orders/SO-2231\" },\n        { \"ref\": \"Remind me later\", \"payload\": \"SNOOZE_24H\" },\n        { \"ref\": \"Copy code\",       \"coupon_code\": \"SAVE20\" },\n        { \"ref\": \"Book a slot\",     \"flow_token\": \"bk_7f3a91\" }\n      ]\n    }\n  }'\n```\n\n- **`ref`** — the label, not a position. Reordering the buttons on an approved template therefore cannot break your integration; everywhere else in the market you pass an index, and a reorder silently lands your coupon on the wrong button. A numeric `ref` still works as an index.\n- **`url_suffix`** — the part appended **after** the approved base URL. Never a whole URL: Meta concatenates, so a full URL produces a broken link with no error.\n- You cannot mislabel a button — its kind comes from the approved template. A `ref` matching no button is ignored rather than guessed at, and a `ref` with no value is dropped rather than sent empty (Meta refuses the *whole* message over one malformed parameter).\n- **Flow token** — when the template carries a Flow button, the send response includes a `flow_token` (the one you passed, or one Omnio minted). When the recipient submits the flow, the `omnio.message.inbound` webhook carries that **same** `flow_token`, so you can tie the response back to the exact send that prompted it.\n\n---\n\n## 5. Authentication (OTP) templates\n\nThe simplest send in the API — **one variable, nothing else**:\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/whatsapp/messages \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"to\": \"+919876543210\",\n    \"type\": \"template\",\n    \"template\": { \"name\": \"login-otp\", \"variables\": [\"482913\"] }\n  }'\n```\n\nMeta requires the code on **both** the message body and the copy button. That is handled for you from the single value — do not send a `buttons` array (one is ignored if you do), and authentication templates have no media header.\n\n---\n\n## 6. Carousels\n\nA carousel template carries its header and buttons **per card**, mirroring Meta's own nesting — so `carousel` replaces `header` and `buttons` rather than sitting beside them.\n\n```bash\ncurl -X POST https://omniosuite.com/api/v1/whatsapp/messages \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"to\": \"+919876543210\",\n    \"type\": \"template\",\n    \"template\": {\n      \"name\": \"summer-sale\",\n      \"variables\": { \"name\": \"Ravi\" },\n      \"carousel\": [\n        { \"header\": { \"media\": { \"id\": \"1602186516975000\" } },\n          \"buttons\": [ { \"ref\": \"Buy now\", \"url_suffix\": \"p/shoes\" } ] },\n        { \"header\": { \"media\": { \"id\": \"1602186516975111\" } },\n          \"buttons\": [ { \"ref\": \"Buy now\", \"url_suffix\": \"p/bags\" } ] }\n      ]\n    }\n  }'\n```\n\nMeta never reuses a carousel's approved sample media, so **every card needs an asset on every send**.\n\n---\n\n## 7. Session (free-form) messages\n\nInside the 24-hour customer-service window you can reply without a template. Same `type` + same-named object form:\n\n```bash\n# plain text\ncurl -X POST https://omniosuite.com/api/v1/whatsapp/messages \\\n  -H \"Authorization: Bearer sandesh_live_xxx\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{ \"to\": \"+919876543210\", \"type\": \"text\", \"text\": { \"body\": \"On its way!\" } }'\n```\n\n```jsonc\n{ \"to\": \"+919876543210\", \"type\": \"image\",\n  \"image\": { \"media\": { \"id\": \"1602186516975000\" }, \"caption\": \"Your parcel\" } }\n\n{ \"to\": \"+919876543210\", \"type\": \"document\",\n  \"document\": { \"media\": { \"url\": \"https://cdn.example.com/inv.pdf\" }, \"filename\": \"invoice.pdf\" } }\n```\n\nOutside the window Meta accepts nothing but an approved template, and the call is refused with `422 wa_session_window_closed`. These land in your Developer inbox alongside the customer's replies.\n\n---\n\n## 8. Everything else\n\n```jsonc\n{\n  \"to\": \"+919876543210\",\n  \"type\": \"template\",\n  \"template\": { \"name\": \"order-shipped\", \"variables\": { \"name\": \"Ravi\" } },\n\n  \"from\": \"+919000000000\",              // which of your numbers to send from (or its phone_number_id)\n  \"context_message_id\": \"wamid.HBg…\",   // quote an earlier message — renders as a reply\n  \"idempotency_key\": \"ord-9000\",        // a retry with the same key replays instead of re-sending\n  \"correlation_id\": \"job-7781\"          // your own reference, echoed on the log + webhooks\n}\n```\n\n**Escape hatch.** Meta ships new component types faster than any wrapper models them. Pass a raw Meta `components[]` array and it is forwarded untouched — mutually exclusive with `header` / `buttons` / `carousel`:\n\n```jsonc\n\"template\": { \"name\": \"limited-offer\", \"components\": [ /* raw Meta components */ ] }\n```\n\n**Responses.** `201` with the delivery log (`data.id`, `data.status`) on a template send, or the inbox message on a session send. `422 send_refused` carries the reason verbatim — including Meta's own text, so `(#132018)` or a missing-parameter complaint tells you what to fix. A send refused for **billing** is the exception: it returns `insufficient_funds`, `credit_limit` or `account_suspended` as its `error.code` (see *When a send is refused for billing* in the Messages section) rather than `send_refused`."}],"paths":{"/campaigns":{"get":{"operationId":"listCampaigns","tags":["Audiences"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/campaigns/{id}":{"get":{"operationId":"getCampaign","tags":["Audiences"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/contacts":{"post":{"operationId":"upsertContact","summary":"Upsert a contact. Idempotent on external_id (per Segment spec).\nIf external_id isn't given, dedupes on email then phone","tags":["Contacts"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"external_id":{"type":["string","null"],"maxLength":191},"email":{"type":["string","null"],"format":"email","maxLength":191},"phone":{"type":["string","null"],"maxLength":32},"name":{"type":["string","null"],"maxLength":191},"locale":{"type":["string","null"],"maxLength":8},"timezone":{"type":["string","null"],"maxLength":64},"traits":{"type":["array","null"],"items":{"type":"string"}}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"external_id":{"type":["string","null"]},"email":{"type":["string","null"]},"phone":{"type":["string","null"]},"name":{"type":["string","null"]},"locale":{"type":["string","null"]},"timezone":{"type":["string","null"]},"traits":{"type":"array","items":{}},"first_seen_at":{"type":"string"},"last_seen_at":{"type":"string"}},"required":["id","external_id","email","phone","name","locale","timezone","traits","first_seen_at","last_seen_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/contacts/{id}":{"get":{"operationId":"getContact","tags":["Contacts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"}},"required":["code"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/contacts/{id}/consent":{"post":{"operationId":"recordContactConsent","tags":["Contacts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"channel":{"type":"string","enum":["sms","whatsapp","email","push","inapp","voice","webhook"]},"state":{"type":"string","enum":["granted","revoked"]},"source":{"type":["string","null"],"maxLength":32},"evidence":{"type":["array","null"],"items":{"type":"string"}}},"required":["channel","state"]}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"contact_id":{"type":"string"},"channel":{"type":"string"},"state":{"type":"string"},"source":{"type":"string"},"effective_at":{"type":"string"}},"required":["id","contact_id","channel","state","source","effective_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/contacts/{id}/preferences":{"get":{"operationId":"getContactPreferences","tags":["Contacts"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"contact_id":{"type":"string"},"preferences":{"type":"array","items":{"type":"object","properties":{"channel":{"type":"string"},"enabled":{"type":"boolean"},"rank":{"type":"integer"},"frequency_cap_per_day":{"type":["integer","null"]},"quiet_hours":{"type":["object","null"],"properties":{"start":{"type":["string","null"]},"end":{"type":["string","null"]},"tz":{"type":["string","null"]}},"required":["start","end","tz"]}},"required":["channel","enabled","rank","frequency_cap_per_day","quiet_hours"]}}},"required":["contact_id","preferences"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/delivery-log":{"get":{"operationId":"listDeliveryLog","tags":["Messages"],"parameters":[{"name":"channel","in":"query","schema":{"type":"string"}},{"name":"status","in":"query","schema":{"type":"string"}},{"name":"correlation_id","in":"query","schema":{"type":"string"}},{"name":"sent_after","in":"query","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"message_id":{"type":"string"},"contact_id":{"type":["string","null"]},"channel":{"type":"string"},"provider":{"type":["string","null"]},"status":{"type":"string"},"correlation_id":{"type":["string","null"]},"queued_at":{"type":"string"},"sent_at":{"type":"string"},"delivered_at":{"type":"string"}},"required":["id","message_id","contact_id","channel","provider","status","correlation_id","queued_at","sent_at","delivered_at"]}},"meta":{"type":"object","properties":{"current_page":{"type":"integer"},"per_page":{"type":"integer"},"total":{"type":"integer"},"last_page":{"type":"integer"}},"required":["current_page","per_page","total","last_page"]}},"required":["data","meta"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/delivery-log/{id}":{"get":{"operationId":"getDeliveryLogEntry","tags":["Messages"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"message_id":{"type":"string"},"contact_id":{"type":"string"},"channel":{"type":"string"},"provider":{"anyOf":[{"type":"string"},{"type":"string","enum":["managed"]}]},"provider_message_id":{"type":"string"},"status":{"type":"string"},"rendered_subject":{"type":"string"},"rendered_body":{"type":"string"},"correlation_id":{"type":"string"},"queued_at":{"type":"string"},"sent_at":{"type":"string"},"delivered_at":{"type":"string"},"failed_at":{"type":"string"},"failure_code":{"type":"string","description":"Managed: our code (the carrier's raw code is not ours to hand out); BYOK:\nraw. `error` is added for both."},"error":{"type":"string"},"attempts":{"type":"string"}},"required":["id","message_id","contact_id","channel","provider","provider_message_id","status","rendered_subject","rendered_body","correlation_id","queued_at","sent_at","delivered_at","failed_at","failure_code","error","attempts"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"}},"required":["code"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/emails":{"post":{"operationId":"sendEmail","tags":["Email"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmailSendRequest"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"string"},{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}]}}}},"201":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"message_id":{"type":"string"},"tenant_id":{"type":"string"},"contact_id":{"type":["string","null"]},"template_id":{"type":["string","null"]},"channel":{"type":"string"},"recipient":{"type":["string","null"]},"provider":{"type":["string","null"]},"provider_message_id":{"type":["string","null"]},"status":{"type":"string"},"failure_code":{"type":"string"},"failure_reason":{"type":"string","description":"delivery_log is write-once, so every money block recorded before the\ncustomer-safe codes existed is still sitting there with our runbook text\n(\"…switch the tenant to postpaid\"). Every other reader on this branch scrubs\nit; this one backs GET /v1/messages/{id} AND the idempotent_replay body of\n/v1/messages, /bulk, /v1/notify and /v1/whatsapp — five endpoints, one line.\nprovider_slug is passed so a GATEWAY's own \"insufficient_credits…\" rejection\nis left alone rather than relabelled as our billing problem."},"error":{"type":"string"},"cost":{"type":["object","null"],"properties":{"amount":{"type":["string","null"]},"currency":{"type":["string","null"]}},"required":["amount","currency"]},"correlation_id":{"type":["string","null"]},"idempotency_key":{"type":["string","null"]},"queued_at":{"type":"string"},"sent_at":{"type":"string"},"delivered_at":{"type":"string"},"read_at":{"type":"string"},"failed_at":{"type":"string"},"flow_token":{"type":"string"}},"required":["id","message_id","tenant_id","contact_id","template_id","channel","recipient","provider","provider_message_id","status","failure_code","failure_reason","error","cost","correlation_id","idempotency_key","queued_at","sent_at","delivered_at","read_at","failed_at","flow_token"]}},"required":["data"]}}}},"202":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"null"},"message_id":{"type":"string"},"tenant_id":{"type":"string"},"contact_id":{"type":"null"},"template_id":{"type":"string"},"channel":{"type":"string","const":"email"},"recipient":{"type":"string"},"provider":{"type":"null"},"provider_message_id":{"type":"null"},"status":{"type":"string","const":"queued"},"failure_code":{"type":"null"},"failure_reason":{"type":"null"},"error":{"type":"null"},"cost":{"type":"null"},"correlation_id":{},"idempotency_key":{"type":["string","null"]},"queued_at":{"type":"string"},"sent_at":{"type":"null"},"delivered_at":{"type":"null"},"read_at":{"type":"null"},"failed_at":{"type":"null"}},"required":["id","message_id","tenant_id","contact_id","template_id","channel","recipient","provider","provider_message_id","status","failure_code","failure_reason","error","cost","correlation_id","idempotency_key","queued_at","sent_at","delivered_at","read_at","failed_at"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"},"500":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"db_error"},"message":{"type":"string","const":"Persisting the delivery log failed."}},"required":["code","message"]}},"required":["error"]}}}}}}},"/events":{"post":{"operationId":"trackEvent","tags":["Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","enum":["track","identify","group","page","screen"]},"event":{"type":["string","null"],"maxLength":128},"userId":{"type":["string","null"],"maxLength":191},"anonymousId":{"type":["string","null"],"maxLength":191},"properties":{"type":["array","null"],"items":{"type":"string"}},"traits":{"type":["array","null"],"items":{"type":"string"}},"context":{"type":["array","null"],"items":{"type":"string"}},"timestamp":{"type":["string","null"],"format":"date-time"},"idempotency_key":{"type":["string","null"],"maxLength":128}},"required":["type"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"accepted":{"type":"boolean"},"event_id":{},"idempotent_replay":{"type":"boolean"}},"required":["accepted","event_id","idempotent_replay"]}},"required":["data"]}}}},"202":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"accepted":{"type":"boolean"},"event_id":{"type":"string"}},"required":["accepted","event_id"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/inbox":{"get":{"operationId":"listInboxItems","tags":["In-app inbox"],"parameters":[{"name":"include_read","in":"query","schema":{"type":"boolean","default":false}},{"name":"include_archived","in":"query","schema":{"type":"boolean","default":false}},{"name":"per_page","in":"query","schema":{"type":"integer","default":25}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InAppNotification"}},"meta":{"type":"object","properties":{"total":{"type":"integer"},"per_page":{"type":"integer"},"current_page":{"type":"integer"},"last_page":{"type":"integer"}},"required":["total","per_page","current_page","last_page"]}},"required":["data","meta"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","const":"contact_id or external_id required"}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/inbox/unread-count":{"get":{"operationId":"getInboxUnreadCount","tags":["In-app inbox"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"unread":{"type":"integer"}},"required":["unread"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","const":"contact_id or external_id required"}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/inbox/read-all":{"post":{"operationId":"markAllInboxItemsRead","tags":["In-app inbox"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"marked_read":{"type":"integer"}},"required":["marked_read"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","const":"contact_id or external_id required"}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/inbox/{id}/read":{"post":{"operationId":"markInboxItemRead","tags":["In-app inbox"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"read_at":{"type":"string"}},"required":["read_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/inbox/{id}/archive":{"post":{"operationId":"archiveInboxItem","tags":["In-app inbox"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"archived_at":{"type":"string"}},"required":["archived_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/journeys":{"get":{"operationId":"listJourneys","tags":["Audiences"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/journeys/{id}":{"get":{"operationId":"getJourney","tags":["Audiences"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/journeys/{id}/enroll":{"post":{"operationId":"enrollContactInJourney","summary":"Manually enroll a contact into a journey. Used for journeys with\nentry_trigger_type=manual or to test-fire existing flows","tags":["Audiences"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"contact_id":{"type":"string","format":"uuid"},"context":{"type":["array","null"],"items":{"type":"string"}}},"required":["contact_id"]}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"run_id":{"type":"string"},"state":{"type":"string"},"current_node_id":{"type":["string","null"]}},"required":["run_id","state","current_node_id"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/messages":{"post":{"operationId":"sendMessage","tags":["Messages"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"string"},{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"contact_not_found"},"message":{"type":"string","const":"Could not resolve contact from to.{contact_id|external_id|email|phone}."}},"required":["code","message"]}},"required":["error"]}}}},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"},"500":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"db_error"},"message":{"type":"string","const":"Persisting the delivery log failed."}},"required":["code","message"]}},"required":["error"]}}}}}}},"/messages/bulk":{"post":{"operationId":"sendBulkMessages","tags":["Messages"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"template":{"type":"string","maxLength":128},"idempotency_key_prefix":{"type":["string","null"],"maxLength":96},"correlation_id":{"type":["string","null"],"maxLength":128},"skip_consent":{"type":["boolean","null"]},"recipients":{"type":"array","items":{"type":"object","properties":{"contact_id":{"type":["string","null"],"format":"uuid"},"external_id":{"type":["string","null"],"maxLength":191},"email":{"type":["string","null"],"format":"email"},"phone":{"type":["string","null"],"maxLength":32},"variables":{"type":["array","null"],"items":{"type":"string"}}}},"minItems":1,"maxItems":10000}},"required":["template","recipients"]}}}},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string"}},"errors":{"type":"array","items":{"anyOf":[{"type":"object","properties":{"index":{"type":"string"},"code":{"type":"string","const":"contact_not_found"}},"required":["index","code"]},{"type":"object","properties":{"index":{"type":"string"},"code":{"type":"string","const":"db_error"},"message":{"type":"string","const":"Persisting the delivery log failed."}},"required":["index","code","message"]},{"type":"string"},{"type":"object","properties":{"index":{"type":"string"},"code":{"type":"string","const":"send_refused"},"message":{"type":"string"}},"required":["index","code","message"]}]}},"meta":{"type":"object","properties":{"requested":{"type":"integer"},"accepted":{"type":"integer"},"rejected":{"type":"integer"}},"required":["requested","accepted","rejected"]}},"required":["data","errors","meta"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/messages/{id}":{"get":{"operationId":"getMessage","tags":["Messages"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"message_id":{"type":"string"},"tenant_id":{"type":"string"},"contact_id":{"type":["string","null"]},"template_id":{"type":["string","null"]},"channel":{"type":"string"},"recipient":{"type":["string","null"]},"provider":{"type":["string","null"]},"provider_message_id":{"type":["string","null"]},"status":{"type":"string"},"failure_code":{"type":"string"},"failure_reason":{"type":"string","description":"delivery_log is write-once, so every money block recorded before the\ncustomer-safe codes existed is still sitting there with our runbook text\n(\"…switch the tenant to postpaid\"). Every other reader on this branch scrubs\nit; this one backs GET /v1/messages/{id} AND the idempotent_replay body of\n/v1/messages, /bulk, /v1/notify and /v1/whatsapp — five endpoints, one line.\nprovider_slug is passed so a GATEWAY's own \"insufficient_credits…\" rejection\nis left alone rather than relabelled as our billing problem."},"error":{"type":"string"},"cost":{"type":["object","null"],"properties":{"amount":{"type":["string","null"]},"currency":{"type":["string","null"]}},"required":["amount","currency"]},"correlation_id":{"type":["string","null"]},"idempotency_key":{"type":["string","null"]},"queued_at":{"type":"string"},"sent_at":{"type":"string"},"delivered_at":{"type":"string"},"read_at":{"type":"string"},"failed_at":{"type":"string"},"flow_token":{"type":"string"}},"required":["id","message_id","tenant_id","contact_id","template_id","channel","recipient","provider","provider_message_id","status","failure_code","failure_reason","error","cost","correlation_id","idempotency_key","queued_at","sent_at","delivered_at","read_at","failed_at","flow_token"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"null"},"message_id":{"type":"string"},"tenant_id":{"type":"string"},"contact_id":{"type":"null"},"template_id":{"type":["string","null"]},"channel":{"type":"string","const":"email"},"recipient":{"type":"string"},"provider":{"type":"null"},"provider_message_id":{"type":"null"},"status":{"type":"string","const":"queued"},"failure_code":{"type":"null"},"failure_reason":{"type":"null"},"error":{"type":"null"},"cost":{"type":"null"},"correlation_id":{"type":["string","null"]},"idempotency_key":{"type":["string","null"]},"queued_at":{"type":"string"},"sent_at":{"type":"null"},"delivered_at":{"type":"null"},"read_at":{"type":"null"},"failed_at":{"type":"null"}},"required":["id","message_id","tenant_id","contact_id","template_id","channel","recipient","provider","provider_message_id","status","failure_code","failure_reason","error","cost","correlation_id","idempotency_key","queued_at","sent_at","delivered_at","read_at","failed_at"]}},"required":["data"]}]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"}},"required":["code"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/notify/{channel}":{"post":{"operationId":"sendNotification","tags":["Notifications"],"parameters":[{"name":"channel","in":"path","required":true,"description":"Delivery channel — one of: `sms`, `whatsapp`, `email`, `push`, `voice`, `webhook`.","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/NotifyRequest"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object"}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/openapi.json":{"get":{"operationId":"getOpenApiDocument","tags":["Meta"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"string"}}},"headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","const":"*"}},"Cache-Control":{"schema":{"type":"string","const":"public, max-age=300"}},"ETag":{"schema":{"type":"string"}}}},"304":{"description":"","headers":{"Access-Control-Allow-Origin":{"schema":{"type":"string","const":"*"}},"Cache-Control":{"schema":{"type":"string","const":"public, max-age=300"}},"ETag":{"schema":{"type":"string"}}}}},"security":[]}},"/sdk/devices":{"post":{"operationId":"registerSdkDevice","tags":["Push notifications"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"writeKey":{"type":"string","description":"Your write key (an API key). Identifies the tenant; safe to embed in apps.","maxLength":191},"userId":{"type":["string","null"],"description":"Your own user id for this user. Resolved to a contact (external_id); created if new.\nOptional — anonymous by default; install_id (below) identifies the device when no\nuserId/anonymousId is sent, so the visitor is still a distinct contact.","maxLength":191},"anonymousId":{"type":["string","null"],"description":"Anonymous id for a not-yet-identified user (fallback when there's no userId).","maxLength":191},"identityHash":{"type":["string","null"],"description":"Identity-verification hash = hmac_sha256(userId, your push identity secret),\ncomputed by YOUR backend. Required for a publishable key to bind the device\nto a real userId (else the userId is ignored and the device is anonymous).","maxLength":191},"platform":{"type":"string","description":"Device platform. ios→APNs, android→FCM, web→WebPush.","enum":["ios","android","web"]},"provider_slug":{"type":["string","null"],"description":"Optional provider override (fcm | apns | web_push | onesignal | …).\nThe Omnio web widget sends this from GET /v1/sdk/config's `provider`.\nDefaults from the platform when omitted.","maxLength":32},"provider_credential_id":{"type":["string","null"],"description":"The push credential (project) this token was minted against — the `id`\nreturned by GET /v1/sdk/config. Pins the device to that project so a\nmanaged send never routes the token to a different Firebase/APNs project.","maxLength":64},"token":{"type":["string","null"],"description":"Push token from a provider SDK (FCM / APNs / OneSignal). Present for native\nAND for web tokens (FCM-web / OneSignal-web are platform=web but token-based).\nExactly one of token / subscription is required — the raw Web Push (VAPID)\npath carries a subscription instead, so keying the requirement off provider/\npayload (not platform) lets a web device register a token.","maxLength":512},"app_version":{"type":["string","null"],"description":"App version, for diagnostics (e.g. \"1.4.0\").","maxLength":32},"device_model":{"type":["string","null"],"description":"Device model (e.g. \"Pixel 8\", \"iPhone 15\").","maxLength":64},"os_version":{"type":["string","null"],"description":"OS version (e.g. \"Android 14\", \"iOS 17.2\").","maxLength":32},"install_id":{"type":["string","null"],"description":"Stable per-install id (a UUID the SDK generates once + persists). Feeds the\ndevice fingerprint so a rotated push token updates the same row rather than\nduplicating. Optional — dedup falls back to the token/endpoint.","maxLength":64},"locale":{"type":["string","null"],"description":"BCP-47 locale of the device (e.g. \"en\", \"hi\").","maxLength":8},"timezone":{"type":["string","null"],"description":"IANA timezone of the device (e.g. \"Asia/Kolkata\"). Used for quiet-hours.","maxLength":64},"user_agent":{"type":["string","null"],"description":"Browser/user-agent string (web devices).","maxLength":512},"meta":{"type":["array","null"],"description":"Arbitrary key-value metadata stored with the device.","items":{"type":"string"}},"subscription":{"type":"object","description":"Raw Web Push (VAPID) subscription from the browser PushManager. Required only\nwhen there's no token (i.e. the provider is web_push, not FCM/OneSignal).","properties":{"endpoint":{"type":"string","description":"The push service endpoint URL from the browser subscription.","maxLength":512},"keys":{"type":"object","description":"The subscription's encryption key pair.","properties":{"p256dh":{"type":"string","description":"base64url-encoded ECDH P-256 public key (p256dh).","maxLength":255},"auth":{"type":"string","description":"base64url-encoded auth secret.","maxLength":255}}}}}},"required":["writeKey","platform"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"string"}}}},"409":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"device_owned_by_another_contact"}},"required":["code"]}},"required":["error"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"tenant_not_found"}},"required":["code"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"invalid_write_key"}},"required":["code"]}},"required":["error"]}]}}}},"422":{"$ref":"#/components/responses/ValidationException"}},"security":[]}},"/sdk/devices/unsubscribe":{"post":{"operationId":"unsubscribeSdkDevice","description":"The widget already drops the browser subscription client-side (deleteToken +\npushManager.unsubscribe); this makes the opt-out authoritative server-side so the\ndevice stops counting as an active subscriber immediately, rather than waiting for\nthe next send to fail and prune it. Identified by the raw token (native/FCM-web) or\nthe web-push endpoint — tenant-scoped, so one tenant can't disable another's device.","summary":"Web push opt-OUT for the embeddable widget's bell — POST /v1/sdk/devices/unsubscribe","tags":["Push notifications"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"writeKey":{"type":"string","description":"Your write key (an API key) — identifies the tenant.","maxLength":191},"token":{"type":["string","null"],"description":"The native/FCM token to opt out (required unless `endpoint` is given).","maxLength":512},"endpoint":{"type":["string","null"],"description":"The web-push endpoint to opt out (required unless `token` is given).","maxLength":512}},"required":["writeKey"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"unsubscribed":{"type":"boolean"},"id":{"type":"string"}},"required":["unsubscribed","id"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"object","properties":{"unsubscribed":{"type":"boolean"}},"required":["unsubscribed"]}},"required":["data"]}]}}}},"401":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"tenant_not_found"}},"required":["code"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"invalid_write_key"}},"required":["code"]}},"required":["error"]}]}}}},"422":{"$ref":"#/components/responses/ValidationException"}},"security":[]}},"/sdk/devices/rotate":{"post":{"operationId":"rotateSdkDeviceToken","description":"Browsers rotate a push subscription (new endpoint+keys) without warning — and the\n'pushsubscriptionchange' service-worker event fires even when the page is closed.\nThe widget self-heals on the next page visit, but a subscription that rotates while\nthe user is away would go silently dead until they return. The service worker calls\nthis to re-point the EXISTING device row to the new subscription IN PLACE — no\ncontact resolution needed (the device already exists from the first subscribe), so\nthe contact binding + geo + traits are all preserved. Matched by the OLD endpoint.","summary":"Web-push subscription ROTATION — POST /v1/sdk/devices/rotate","tags":["Push notifications"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"writeKey":{"type":"string","maxLength":191},"old_endpoint":{"type":["string","null"],"description":"The endpoint that is rotating away (from event.oldSubscription).","maxLength":512},"subscription":{"type":"object","properties":{"endpoint":{"type":"string","maxLength":512},"keys":{"type":"object","properties":{"p256dh":{"type":"string","maxLength":255},"auth":{"type":"string","maxLength":255}},"required":["p256dh","auth"]}},"required":["endpoint","keys"]}},"required":["writeKey","subscription"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"rotated":{"type":"boolean"},"id":{"type":"string"}},"required":["rotated","id"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"object","properties":{"rotated":{"type":"boolean"}},"required":["rotated"]}},"required":["data"]}]}}}},"401":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"tenant_not_found"}},"required":["code"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"invalid_write_key"}},"required":["code"]}},"required":["error"]}]}}}},"422":{"$ref":"#/components/responses/ValidationException"}},"security":[]}},"/sdk/push/receipt":{"post":{"operationId":"recordSdkPushReceipt","tags":["Push notifications"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"writeKey":{"type":"string","description":"Your write key (an API key). Identifies the tenant; safe to embed.","maxLength":191},"message_id":{"type":"string","description":"The internal message id the push carried in data.omnio_message_id.","maxLength":191},"event":{"type":"string","description":"'delivered' = the push reached the device; 'clicked' = the user opened it.","enum":["delivered","clicked"]}},"required":["writeKey","message_id","event"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"ok":{"type":"boolean"},"status":{"type":"string"}},"required":["ok","status"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}},"required":["data"]}]}}}},"401":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"invalid_write_key"}},"required":["code"]}},"required":["error"]}}}},"422":{"$ref":"#/components/responses/ValidationException"}},"security":[]}},"/sdk/config":{"get":{"operationId":"getSdkConfig","tags":["Push notifications"],"parameters":[{"name":"writeKey","in":"query","required":true,"description":"Your write key (an API key). Identifies the tenant; safe to embed in apps.","schema":{"type":"string","maxLength":191}},{"name":"platform","in":"query","description":"Optional. ios | android | web. Omit to get the config for every platform.","schema":{"type":["string","null"],"enum":["ios","android","web"]}}],"responses":{"200":{"description":"`providers` = every credential for this platform (primary first) so a\nmulti-token client can register each; the primary stays at the top level.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"platforms":{"type":"string"},"providers":{"type":"string"}},"required":["platforms","providers"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}]}}}},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"no_push_credential"},"platform":{"type":"string"}},"required":["code","platform"]}},"required":["error"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"tenant_not_found"}},"required":["code"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"invalid_write_key"}},"required":["code"]}},"required":["error"]}]}}}},"422":{"$ref":"#/components/responses/ValidationException"}},"security":[]}},"/sdk/widget":{"get":{"operationId":"getSdkWidget","description":"Returns the tenant's saved opt-in prompt look & behaviour (style, position,\ncolours, copy, bell) that they configured in the dashboard. The embeddable\nwidget fetches this at load and applies it, so the embed snippet is just the\nwrite key — no hand-edited data-* attributes. Whitelisted, non-secret config\nonly; lightly cacheable.","summary":"Public web-push WIDGET appearance — GET /v1/sdk/widget?writeKey=…","tags":["Push notifications"],"parameters":[{"name":"writeKey","in":"query","required":true,"description":"Your write key (an API key). Identifies the tenant; safe to embed.","schema":{"type":"string","maxLength":191}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{}}},"required":["data"]}}}},"401":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"tenant_not_found"}},"required":["code"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"invalid_write_key"}},"required":["code"]}},"required":["error"]}]}}}},"422":{"$ref":"#/components/responses/ValidationException"}},"security":[]}},"/contacts/{contactId}/devices":{"get":{"operationId":"listContactsDevices","summary":"Server-to-server (secret key) — list a contact's registered push devices\n(most-recently-seen first). Most apps use the public /v1/sdk/* endpoints instead","tags":["Contacts"],"parameters":[{"name":"contactId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"contact_id":{"type":"string"},"platform":{"type":"string"},"provider_slug":{"type":["string","null"]},"token":{"type":["string","null"]},"web_push_endpoint":{"type":["string","null"]},"app_version":{"type":["string","null"]},"device_model":{"type":["string","null"]},"os_version":{"type":["string","null"]},"locale":{"type":["string","null"]},"timezone":{"type":["string","null"]},"is_active":{"type":"boolean"},"last_seen_at":{"type":"string"},"invalidated_at":{"type":"string"},"invalidation_reason":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","contact_id","platform","provider_slug","token","web_push_endpoint","app_version","device_model","os_version","locale","timezone","is_active","last_seen_at","invalidated_at","invalidation_reason","created_at"]}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"}},"required":["code"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}},"post":{"operationId":"createContactsDevice","description":"Ownership transfer: if the token already belongs to ANOTHER contact (shared\ndevice, logout→login), ownership moves to this contact. A token maps to exactly\none contact, so the previous owner can never receive this device's notifications\nagain — closing the cross-user notification-leak hole.","summary":"Server-to-server (secret key) — register (or refresh) a push device for a contact.\nIdempotent on the physical token / web endpoint: re-sending the same token updates\nthe existing row. Client apps should use POST /v1/sdk/devices instead","tags":["Contacts"],"parameters":[{"name":"contactId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterDeviceRequest"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"}},"required":["code"]}},"required":["error"]}}}},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/contacts/{contactId}/devices/{deviceId}":{"patch":{"operationId":"updateContactsDevice","description":"Client SDKs don't call this — they have no contact id or device id. On FCM's\nonNewToken (or an APNs re-issue) the SDK simply re-POSTs the new token to\n/v1/sdk/devices (idempotent), and the stale token is pruned when its next send fails.","summary":"Server-to-server (secret key) — refresh a device in place by id (token rotation /\nheartbeat). Preserves the same device row (and its history/metadata) across a token change","tags":["Contacts"],"parameters":[{"name":"contactId","in":"path","required":true,"schema":{"type":"string"}},{"name":"deviceId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":["string","null"],"maxLength":512},"app_version":{"type":["string","null"],"maxLength":32},"os_version":{"type":["string","null"],"maxLength":32},"locale":{"type":["string","null"],"maxLength":8},"timezone":{"type":["string","null"],"maxLength":64}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"contact_id":{"type":"string"},"platform":{"type":"string"},"provider_slug":{"type":["string","null"]},"token":{"type":["string","null"]},"web_push_endpoint":{"type":["string","null"]},"app_version":{"type":["string","null"]},"device_model":{"type":["string","null"]},"os_version":{"type":["string","null"]},"locale":{"type":["string","null"]},"timezone":{"type":["string","null"]},"is_active":{"type":"boolean"},"last_seen_at":{"type":"string"},"invalidated_at":{"type":"string"},"invalidation_reason":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","contact_id","platform","provider_slug","token","web_push_endpoint","app_version","device_model","os_version","locale","timezone","is_active","last_seen_at","invalidated_at","invalidation_reason","created_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"}},"required":["code"]}},"required":["error"]}}}},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}},"delete":{"operationId":"deleteContactsDevice","summary":"Server-to-server (secret key) — unregister a device (logout / client uninstall hint).\nSoft-disables it (is_active=false) rather than deleting, so a later re-login\nreactivates the same row and the unique-token index stays intact for ownership\ntracking. Client/widget opt-out uses POST /v1/sdk/devices/unsubscribe instead","tags":["Contacts"],"parameters":[{"name":"contactId","in":"path","required":true,"schema":{"type":"string"}},{"name":"deviceId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"contact_id":{"type":"string"},"platform":{"type":"string"},"provider_slug":{"type":["string","null"]},"token":{"type":["string","null"]},"web_push_endpoint":{"type":["string","null"]},"app_version":{"type":["string","null"]},"device_model":{"type":["string","null"]},"os_version":{"type":["string","null"]},"locale":{"type":["string","null"]},"timezone":{"type":["string","null"]},"is_active":{"type":"boolean"},"last_seen_at":{"type":"string"},"invalidated_at":{"type":"string"},"invalidation_reason":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["id","contact_id","platform","provider_slug","token","web_push_endpoint","app_version","device_model","os_version","locale","timezone","is_active","last_seen_at","invalidated_at","invalidation_reason","created_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"}},"required":["code"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/sdk/events":{"post":{"operationId":"trackSdkEvents","tags":["Push notifications"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"writeKey":{"type":"string","maxLength":191},"sentAt":{"type":["string","null"],"format":"date-time"},"batch":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["identify","track","page","screen","group"]},"anonymousId":{"type":["string","null"],"maxLength":191},"userId":{"type":["string","null"],"maxLength":191},"identityHash":{"type":["string","null"],"description":"hmac_sha256(userId, push identity secret) — lets a publishable key bind to a real userId.","maxLength":191},"event":{"type":["string","null"],"maxLength":128},"name":{"type":["string","null"],"maxLength":128},"groupId":{"type":["string","null"],"maxLength":191},"properties":{"type":["array","null"],"items":{"type":"string"}},"traits":{"type":["array","null"],"items":{"type":"string"}},"timestamp":{"type":["string","null"],"format":"date-time"},"messageId":{"type":["string","null"],"maxLength":128},"context":{"type":["array","null"],"items":{"type":"string"}}},"required":["type"]},"minItems":1,"maxItems":100}},"required":["writeKey","batch"]}}}},"responses":{"202":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"accepted":{"type":"integer"},"rejected":{"type":"integer"}},"required":["accepted","rejected"]},"errors":{"type":"array","items":{"type":"object","properties":{"index":{"type":"string"},"code":{"type":"string","const":"event_failed"},"message":{"type":"string","const":"This event could not be processed."}},"required":["index","code","message"]}}},"required":["data","errors"]}}}},"422":{"$ref":"#/components/responses/ValidationException"},"401":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"tenant_not_found"}},"required":["code"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"invalid_write_key"}},"required":["code"]}},"required":["error"]}]}}}}},"security":[]}},"/segments":{"get":{"operationId":"listSegments","tags":["Audiences"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","description":"Surface-AGNOSTIC by design: segments are an Engage-native resource and\nthe REST API reads the tenant's own data — filtering by the key's\nsurface would hide every segment from the API. The Tenant scope is the\nreal boundary; the dashboard segment list is the surface-partitioned one.","items":{}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/segments/{id}":{"get":{"operationId":"getSegment","tags":["Audiences"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/templates":{"get":{"operationId":"listTemplates","tags":["Templates"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"slug":{"type":"string"},"display_name":{"type":"string"},"category":{"type":"string"},"status":{"type":"string"},"default_locale":{"type":"string"},"channels":{"type":"array","items":{}},"variables":{"type":"array","description":"The exact keys to send in `variables`, merged across current versions. Named\ntemplates list their names; WhatsApp lists the send keys (\"1\",\"2\",… and\n\"header.1\"/\"body.1\" only where a number repeats across components).","items":{"type":"string"}},"updated_at":{"type":"string"}},"required":["id","slug","display_name","category","status","default_locale","channels","variables","updated_at"]}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}},"post":{"operationId":"createTemplate","description":"201 when the template is new, 200 when a version was added.","summary":"Create a template (with its first version) or add the next version to an\nexisting one — idempotent by `slug`. Every channel the editor authors except\nWhatsApp, which has POST /v1/whatsapp/templates (Meta submission)","tags":["Templates"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"slug":{"type":"string","pattern":"^[a-z0-9][a-z0-9._-]*$","maxLength":128},"display_name":{"type":["string","null"],"maxLength":191},"description":{"type":["string","null"],"maxLength":1000},"category":{"type":["string","null"],"enum":["marketing","utility","transactional","service","otp","authentication"]},"status":{"type":["string","null"],"enum":["active","draft"]},"channel":{"type":"string","enum":["email","sms","push","voice","inapp"]},"locale":{"type":["string","null"],"maxLength":8},"subject":{"type":["string","null"],"maxLength":998},"body":{"type":"string"},"content":{"type":["array","null"],"items":{"type":"string"}},"media_url":{"type":["string","null"],"format":"uri","maxLength":2048},"make_current":{"type":["boolean","null"]}},"required":["slug","channel","body"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/templates/{slug}":{"get":{"operationId":"getTemplate","tags":["Templates"],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"slug":{"type":"string"},"display_name":{"type":"string"},"description":{"type":["string","null"]},"category":{"type":"string"},"status":{"type":"string"},"default_locale":{"type":"string"},"default_variables":{"type":"array","items":{}},"versions":{"type":"array","items":{"type":"object","properties":{"channel":{"type":"string"},"locale":{"type":"string"},"version":{"type":"integer"},"subject":{"type":["string","null"]},"body":{"type":"string"},"content":{"type":["array","null"],"items":{}},"variables":{"type":"array","description":"The exact keys to send in `variables` for this version, in order.\nNamed templates: fill by name, or positionally ([..] / {\"1\":..} /\n{\"var_1\":..}). WhatsApp: a numbered placeholder is keyed by its\nbare number (\"2\"); when the SAME number appears in more than one\ncomponent it is component-scoped instead (\"header.1\" vs \"body.1\")\nso the two stay distinct.","items":{"type":"string"}},"is_current":{"type":"boolean"},"published_at":{"type":"string"}},"required":["channel","locale","version","subject","body","content","variables","is_current","published_at"]}}},"required":["id","slug","display_name","description","category","status","default_locale","default_variables","versions"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"}},"required":["code"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/calls/{call}/recording":{"get":{"operationId":"getCallRecording","description":"Reached only via the signed, expiring link that RecordingService::signedUrl\nbuilds and the voice webhook delivers. The `signed` middleware has already\nverified the signature + expiry before we get here — that IS the\nauthorisation, so the call is loaded without a tenant scope (a webhook\nconsumer holds no session/API key). We fetch the audio from the carrier\nserver-side and stream the bytes back; the receiver never learns which\ncarrier, and there is no stored bucket of call audio to leak.","summary":"GET /v1/calls/{call}/recording — stream a call's recording through OUR OWN\ndomain, so the carrier's signed URL (which names the provider and embeds its\naccount id) is never handed out","tags":["Voice"],"parameters":[{"name":"call","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"string"}}}},"404":{"$ref":"#/components/responses/ModelNotFoundException"}},"security":[]}},"/calls":{"post":{"operationId":"createCall","description":"Place an outbound call that runs the given voice template's flow and return a\nunique `call_id` to track it with (read the status via GET /v1/calls/{id}).\n\nCALLING WINDOW: when the template is mapped to an IVR configuration set whose\ncalling window is closed at this moment, the call is NOT refused — it is PARKED\nand the response is **202** with `status: \"scheduled\"`, the `calling_window`, and\n`scheduled_for`. Our system places it automatically when the window opens; cancel\nit before then with DELETE /v1/calls/{id}. A fresh in-window call is **201**; an\nidempotent replay of a key already used is **200**.","summary":"Originate a call","tags":["Voice"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCallRequest"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"string"},{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}]}}}},"202":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"call_id":{"type":"string"},"status":{"type":"string","const":"scheduled"},"to":{"type":"string"},"template":{"type":"string"},"scheduled_for":{"type":"string","description":"In the config's own timezone so it reads the same as calling_window\n(still an unambiguous ISO-8601 instant, just with the +05:30 offset)."},"calling_window":{"type":"string"},"message":{"type":"string"}},"required":["call_id","status","to","template","scheduled_for","calling_window","message"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"},"500":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"db_error"},"message":{"type":"string","const":"Persisting the call failed."}},"required":["code","message"]}},"required":["error"]}}}}}}},"/calls/{id}":{"get":{"operationId":"getCall","description":"Read the current status of a call by the `call_id` returned from create — including\na PARKED (scheduled) call, which reports `status: \"scheduled\"` until it is placed and\nthen follows through to the live call's status.","summary":"Get a call's status","tags":["Voice"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"call_id":{"type":"string","description":"The stable, unique id to track this call — like a WhatsApp message id."},"status":{"type":"string"},"to":{"type":["string","null"]},"template":{},"provider":{"type":["string","null"],"description":"\"managed\" when the call went through our voice pool; BYOK keeps its carrier."},"provider_call_id":{"type":"string","description":"The carrier's own reference for the live call, once it exists."},"duration_seconds":{"type":"string"},"correlation_id":{"type":["string","null"]},"created_at":{"type":"string"}},"required":["call_id","status","to","template","provider","provider_call_id","duration_seconds","correlation_id","created_at"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"object","properties":{"call_id":{"type":"string"},"status":{"type":"string"},"to":{"type":"string"},"template":{"type":"string"},"scheduled_for":{"type":"string"}},"required":["call_id","status","to","template","scheduled_for"]}},"required":["data"]}]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"},"message":{"type":"string","const":"No call with that id."}},"required":["code","message"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}},"delete":{"operationId":"deleteCall","description":"Cancel a call that is still PARKED — scheduled outside its calling window and not\nyet placed. A call that has already been placed, failed or cancelled returns **409\nnot_cancellable**; an unknown id returns **404**.","summary":"Cancel a scheduled call","tags":["Voice"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"call_id":{"type":"string"},"status":{"type":"string","const":"cancelled"}},"required":["call_id","status"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"},"message":{"type":"string","const":"No scheduled call with that id."}},"required":["code","message"]}},"required":["error"]}}}},"409":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_cancellable"},"message":{"type":"string"}},"required":["code","message"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/calls/{id}/retries":{"delete":{"operationId":"deleteCallsRetry","description":"DELETE /v1/calls/{id}/retries — stop any further re-dials of a call (the call_id\nfrom create). The call that already went out is unaffected; only its PENDING retries\nare cancelled. **404** if the call has no retry plan, **409** if retries already\nfinished (done / exhausted / cancelled).","summary":"Stop retries of a call","tags":["Voice"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"call_id":{"type":"string"},"retries":{"type":"string","const":"cancelled"}},"required":["call_id","retries"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"},"message":{"type":"string","const":"No retry plan for that call."}},"required":["code","message"]}},"required":["error"]},{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"},"message":{"type":"string","const":"No call with that id."}},"required":["code","message"]}},"required":["error"]}]}}}},"409":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_cancellable"},"message":{"type":"string"}},"required":["code","message"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}},"/wallet/balance":{"get":{"operationId":"getWalletBalance","tags":["Wallet"],"responses":{"200":{"description":"negative_limit_minor: how far below zero the balance may go (0 = sends stop at 0);\navailable_minor: what can still be spent before sends are refused.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"currency":{"type":"string"},"balance_minor":{"type":"integer"},"balance_exact":{"type":"string","description":"Exact sub-paise balance (paise to 4dp) — the source of truth; balance_minor\nabove is its rounded projection, kept for existing API consumers."},"negative_limit_minor":{"type":"integer"},"available_minor":{"type":["object","null"]}},"required":["currency","balance_minor","balance_exact","negative_limit_minor","available_minor"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/wallet/ledger":{"get":{"operationId":"listWalletLedger","tags":["Wallet"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"direction":{"type":"string"},"amount_minor":{"type":"integer"},"amount_exact":{"type":"string"},"balance_before_minor":{"type":"string"},"balance_before_exact":{"type":"string","description":"Exact before/after (paise to 4dp) chained on the exact amount, so a\nsub-paise entry's \"before\" is right where the rounded minor would drift."},"balance_after_minor":{"type":"integer"},"balance_after_exact":{"type":"string"},"reference_type":{"type":["string","null"]},"reference_id":{"type":["string","null"]},"description":{"type":"string","description":"Masked label — never leaks the provider slug for Managed sends."},"occurred_at":{"type":"string"}},"required":["id","direction","amount_minor","amount_exact","balance_before_minor","balance_before_exact","balance_after_minor","balance_after_exact","reference_type","reference_id","description","occurred_at"]}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/webhooks/sources":{"get":{"operationId":"listWebhookSources","tags":["Webhooks"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"inbound_url":{"type":"string"},"destination_url":{"type":["string","null"]},"connector_type":{"type":"string"},"connector_template":{"type":["string","null"]},"event_type_filter":{"type":"array","items":{}},"sequence_key_path":{"type":["string","null"]},"idempotency_key_path":{"type":["string","null"]},"event_type_path":{"type":["string","null"]},"verify_scheme":{"type":"string"},"max_attempts":{"type":"integer"},"timeout_seconds":{"type":"integer"},"retention_days":{"type":"integer"},"enabled":{"type":"boolean"},"last_received_at":{"type":"string"},"created_at":{"type":"string"}},"required":["id","name","inbound_url","destination_url","connector_type","connector_template","event_type_filter","sequence_key_path","idempotency_key_path","event_type_path","verify_scheme","max_attempts","timeout_seconds","retention_days","enabled","last_received_at","created_at"]}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}},"post":{"operationId":"createWebhookSource","tags":["Webhooks"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","maxLength":191},"destination_url":{"type":"string","format":"uri","maxLength":2048},"connector_type":{"type":["string","null"],"enum":["direct","slack","discord","teams"]},"connector_template":{"type":["string","null"],"maxLength":4000},"sequence_key_path":{"type":["string","null"],"maxLength":191},"idempotency_key_path":{"type":["string","null"],"maxLength":191},"event_type_path":{"type":["string","null"],"maxLength":191},"verify_scheme":{"type":["string","null"],"enum":["none","hmac_sha256","header_token"]},"max_attempts":{"type":["integer","null"],"minimum":1,"maximum":3},"timeout_seconds":{"type":["integer","null"],"minimum":1,"maximum":5},"event_type_filter":{"type":["array","null"],"items":{"type":"string","maxLength":191}}},"required":["name","destination_url"]}}}},"responses":{"201":{"description":"The signing secret is returned ONCE, here — it is not retrievable later\n(rotate to get a new one). Everything else is on GET too.","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/webhooks/sources/{id}":{"get":{"operationId":"getWebhookSource","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}},"patch":{"operationId":"updateWebhookSource","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":["string","null"],"maxLength":191},"destination_url":{"type":["string","null"],"format":"uri","maxLength":2048},"connector_type":{"type":["string","null"],"enum":["direct","slack","discord","teams"]},"connector_template":{"type":["string","null"],"maxLength":4000},"sequence_key_path":{"type":["string","null"],"maxLength":191},"idempotency_key_path":{"type":["string","null"],"maxLength":191},"event_type_path":{"type":["string","null"],"maxLength":191},"verify_scheme":{"type":["string","null"],"enum":["none","hmac_sha256","header_token"]},"max_attempts":{"type":["integer","null"],"minimum":1,"maximum":3},"timeout_seconds":{"type":["integer","null"],"minimum":1,"maximum":5},"enabled":{"type":["boolean","null"]},"event_type_filter":{"type":["array","null"],"items":{"type":"string","maxLength":191}}}}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"inbound_url":{"type":"string"},"destination_url":{"type":["string","null"]},"connector_type":{"type":"string"},"connector_template":{"type":["string","null"]},"event_type_filter":{"type":"array","items":{}},"sequence_key_path":{"type":["string","null"]},"idempotency_key_path":{"type":["string","null"]},"event_type_path":{"type":["string","null"]},"verify_scheme":{"type":"string"},"max_attempts":{"type":"integer"},"timeout_seconds":{"type":"integer"},"retention_days":{"type":"integer"},"enabled":{"type":"boolean"},"last_received_at":{"type":"string"},"created_at":{"type":"string"}},"required":["id","name","inbound_url","destination_url","connector_type","connector_template","event_type_filter","sequence_key_path","idempotency_key_path","event_type_path","verify_scheme","max_attempts","timeout_seconds","retention_days","enabled","last_received_at","created_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}},"delete":{"operationId":"deleteWebhookSource","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/webhooks/sources/{id}/rotate-secret":{"post":{"operationId":"rotateWebhookSourceSecret","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"signing_secret":{"type":"string"}},"required":["id","signing_secret"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/webhooks/endpoints":{"get":{"operationId":"listWebhookEndpoints","summary":"List your outbound webhook endpoints. The signing secret is never shown again","tags":["Webhooks"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string"}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}},"post":{"operationId":"createWebhookEndpoint","summary":"Create an outbound webhook endpoint. Returns the signing secret ONCE — store\nit; every delivery is signed with it (see the Webhooks guide)","tags":["Webhooks"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri","maxLength":2048},"description":{"type":["string","null"],"maxLength":191},"timeout_seconds":{"type":["integer","null"],"minimum":1,"maximum":5},"channels":{"type":"array","description":"Which channels' events to receive: sms, whatsapp, email, push, inapp, voice.","items":{"type":"string","enum":["sms","whatsapp","email","push","inapp","voice","contacts"]},"minItems":1}},"required":["url","channels"]}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/webhooks/endpoints/{id}":{"delete":{"operationId":"deleteWebhookEndpoint","summary":"Delete an endpoint. Deliveries already queued are dropped","tags":["Webhooks"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No content"},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/whatsapp/media/{credential}/{media}":{"get":{"operationId":"getWhatsappMedia","tags":["WhatsApp"],"parameters":[{"name":"credential","in":"path","required":true,"schema":{"type":"string"}},{"name":"media","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"$ref":"#/components/responses/ModelNotFoundException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/whatsapp/chatbots":{"get":{"operationId":"listWhatsappChatbots","summary":"List this project's active chatbot flows (id + name + trigger), so a caller knows which\n`id` to trigger","tags":["WhatsApp"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"trigger_type":{"type":"string"},"run_count":{"type":"integer"}},"required":["id","name","trigger_type","run_count"]}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/whatsapp/chatbots/{id}/trigger":{"post":{"operationId":"createWhatsappChatbotsTrigger","description":"Body: { to: \"<E.164 or digits>\", variables?: {..}, from?: \"<sender number / phone_number_id>\" }\n\n`variables` is a NAMED object whose keys match the tokens your flow uses — e.g. a node that\nwrites `{{customer_name}}` / `{{tracking_id}}` is filled by\n`\"variables\": {\"customer_name\": \"Mohit\", \"tracking_id\": \"ABC123\"}`. Keys must match the token\nnames exactly; a key the flow never references is ignored, and a token with no value renders\nempty. Only pass a POSITIONAL array (`[\"Mohit\", \"ABC123\"]`) when the flow uses NUMBERED tokens\n(`{{1}}`, `{{2}}`, …) — the array fills `{{1}}` from the first element onward; it does NOT map\nonto named tokens. If your flow's first block is a \"Send template\", every value the template\nneeds (its body, header-text and dynamic URL-button variables) must be present here, or Meta\nrejects the send with \"(#131008) Required parameter is missing\".\n\nA started run answers with a `session_id`. Every message the flow then sends carries that\nsame id on its delivery webhook (`omnio.message.*`), so you can tie a run's messages back to\nthis trigger; it is also the run's key in the Automation log.","summary":"Start a chatbot flow for one customer","tags":["WhatsApp"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"to":{"type":"string","maxLength":32},"variables":{"type":["array","null"],"items":{"type":"string"}},"from":{"type":["string","null"],"maxLength":64}},"required":["to"]}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"bot_not_found"},"message":{"type":"string","const":"No chatbot flow with that id in this project."}},"required":["code","message"]}},"required":["error"]}}}},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/whatsapp/media":{"post":{"operationId":"uploadWhatsAppMedia","description":"⚠️ Cannot generate request documentation: Cannot evaluate validation rules (1 evaluators failed):\n  [NodeRulesEvaluator] Cannot access private constant App\\Api\\Controllers\\WhatsAppMediaController::MAX_KB (at /home/omniosuite/htdocs/omniosuite.com/vendor/dedoc/scramble/src/Support/OperationExtensions/RulesEvaluator/ConstFetchEvaluator.php:42)","tags":["WhatsApp"],"responses":{"201":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"mime":{"type":["string","null"]},"filename":{"type":"string"},"size":{"type":"string"},"expires_in_days":{"type":"integer","description":"Meta's documented retention for an uploaded handle.","const":30}},"required":["id","mime","filename","size","expires_in_days"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"null"},"link":{"type":"string"},"mime":{"type":["string","null"]},"filename":{"type":"string"},"size":{"type":"string"}},"required":["id","link","mime","filename","size"]},"warning":{"type":"string"}},"required":["data","warning"]}]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/whatsapp/messages":{"post":{"operationId":"sendWhatsAppMessage","tags":["WhatsApp"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendWhatsAppRequest"}}}},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"string"}},"required":["data"]}}}},"201":{"description":"","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"message_id":{"type":"string"},"tenant_id":{"type":"string"},"contact_id":{"type":["string","null"]},"template_id":{"type":["string","null"]},"channel":{"type":"string"},"recipient":{"type":["string","null"]},"provider":{"type":["string","null"]},"provider_message_id":{"type":["string","null"]},"status":{"type":"string"},"failure_code":{"type":"string"},"failure_reason":{"type":"string","description":"delivery_log is write-once, so every money block recorded before the\ncustomer-safe codes existed is still sitting there with our runbook text\n(\"…switch the tenant to postpaid\"). Every other reader on this branch scrubs\nit; this one backs GET /v1/messages/{id} AND the idempotent_replay body of\n/v1/messages, /bulk, /v1/notify and /v1/whatsapp — five endpoints, one line.\nprovider_slug is passed so a GATEWAY's own \"insufficient_credits…\" rejection\nis left alone rather than relabelled as our billing problem."},"error":{"type":"string"},"cost":{"type":["object","null"],"properties":{"amount":{"type":["string","null"]},"currency":{"type":["string","null"]}},"required":["amount","currency"]},"correlation_id":{"type":["string","null"]},"idempotency_key":{"type":["string","null"]},"queued_at":{"type":"string"},"sent_at":{"type":"string"},"delivered_at":{"type":"string"},"read_at":{"type":"string"},"failed_at":{"type":"string"},"flow_token":{"type":"string"}},"required":["id","message_id","tenant_id","contact_id","template_id","channel","recipient","provider","provider_message_id","status","failure_code","failure_reason","error","cost","correlation_id","idempotency_key","queued_at","sent_at","delivered_at","read_at","failed_at","flow_token"]}},"required":["data"]},{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"conversation_id":{"type":"string"},"provider_message_id":{"type":"string"},"status":{"type":"string"},"type":{"type":"string"},"failure_reason":{"type":"string"},"created_at":{"type":"string"}},"required":["id","conversation_id","provider_message_id","status","type","failure_reason","created_at"]}},"required":["data"]}]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"},"500":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"db_error"},"message":{"type":"string","const":"Persisting the delivery log failed."}},"required":["code","message"]}},"required":["error"]}}}}}}},"/whatsapp/messages/{message}":{"get":{"operationId":"getWhatsappMessage","description":"`{message}` is any id the caller may already hold: the `id` POST /v1/whatsapp/messages\nanswered with (a delivery-log id for a template send, a message id for a session send),\nthe `message_id` every webhook carries, or Meta's own `wamid.…`. The id is deliberately\nNOT constrained to a UUID, unlike every other `{id}` under /v1, because a wamid is not one.\n\nThe envelope is the one WhatsApp API customers already integrate against elsewhere\n(`status` / `msg` / `data`), so a status poll ports with a hostname change. `message_status`\nwalks queued → sent → delivered → read, or ends at failed; each step stamps its own\n`*_at`. `error` is the failure code, `error_details` the provider's full text, and\n`failure_reason` the customer-safe sentence — all empty on a healthy message.","summary":"Read one message's delivery status","tags":["WhatsApp"],"parameters":[{"name":"message","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"integer","const":200}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}}},"/whatsapp/template-media":{"post":{"operationId":"createWhatsappTemplateMedia","description":"DISTINCT from POST /v1/whatsapp/media, which uploads to Meta and returns a SEND-time media\nid — a template submission needs a resumable handle, not a send handle.","summary":"Upload a header/carousel media FILE and get back a public URL to drop into `header.media_url`\n(or `carousel.cards[].media_url`) on a create call — so the developer never has to host the\nfile themselves. Stored per-tenant on the public disk; at submit time the adapter reads it\nstraight off local disk and uploads it to Meta as a resumable-upload handle","tags":["WhatsApp"],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","properties":{"file":{"type":"string","format":"binary","contentMediaType":"application/octet-stream","description":"16MB covers WhatsApp image (5MB) + video (16MB); link bigger documents by URL.","maxLength":16384}},"required":["file"]}}}},"responses":{"201":{"description":"An ABSOLUTE own-host URL: the create call takes it as `header.media_url`, and the adapter\nrecognises the /storage/ path and reads the bytes from local disk (no re-fetch).","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"url":{"type":"string"},"type":{"type":"string","description":"A hint for which header.type / carousel header_type to pair it with.","enum":["video","document","image"]}},"required":["url","type"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/whatsapp/templates":{"get":{"operationId":"listWhatsappTemplates","summary":"List this tenant's WhatsApp template registrations (optionally filtered by status)","tags":["WhatsApp"],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"template_id":{"type":"string"},"slug":{"type":"string","description":"The Omnio slug is what POST /v1/whatsapp/messages takes as `template.name`."},"provider_slug":{"type":"string"},"name":{"type":"string"},"language":{"type":"string"},"category":{"type":"string"},"status":{"type":"string"},"external_id":{"type":["string","null"]},"rejection_reason":{"type":["string","null"]},"source":{"type":"string"},"submitted_at":{"type":"string"},"synced_at":{"type":"string"}},"required":["id","template_id","slug","provider_slug","name","language","category","status","external_id","rejection_reason","source","submitted_at","synced_at"]}}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"429":{"$ref":"#/components/responses/Error429"}}},"post":{"operationId":"createWhatsappTemplate","summary":"Create + submit a WhatsApp template to Meta for approval","tags":["WhatsApp"],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWhatsAppTemplateRequest"}}}},"responses":{"201":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"template_id":{"type":"string"},"slug":{"type":"string","description":"The Omnio slug is what POST /v1/whatsapp/messages takes as `template.name`."},"provider_slug":{"type":"string"},"name":{"type":"string"},"language":{"type":"string"},"category":{"type":"string"},"status":{"type":"string"},"external_id":{"type":["string","null"]},"rejection_reason":{"type":["string","null"]},"source":{"type":"string"},"submitted_at":{"type":"string"},"synced_at":{"type":"string"}},"required":["id","template_id","slug","provider_slug","name","language","category","status","external_id","rejection_reason","source","submitted_at","synced_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"422":{"$ref":"#/components/responses/ValidationException"},"429":{"$ref":"#/components/responses/Error429"}}}},"/whatsapp/templates/{id}":{"get":{"operationId":"getWhatsappTemplate","summary":"One registration's live status — polls Meta while it is still in review","tags":["WhatsApp"],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"id":{"type":"string"},"template_id":{"type":"string"},"slug":{"type":"string","description":"The Omnio slug is what POST /v1/whatsapp/messages takes as `template.name`."},"provider_slug":{"type":"string"},"name":{"type":"string"},"language":{"type":"string"},"category":{"type":"string"},"status":{"type":"string"},"external_id":{"type":["string","null"]},"rejection_reason":{"type":["string","null"]},"source":{"type":"string"},"submitted_at":{"type":"string"},"synced_at":{"type":"string"}},"required":["id","template_id","slug","provider_slug","name","language","category","status","external_id","rejection_reason","source","submitted_at","synced_at"]}},"required":["data"]}}}},"401":{"$ref":"#/components/responses/Error401"},"403":{"$ref":"#/components/responses/Error403"},"404":{"description":"","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","const":"not_found"},"message":{"type":"string","const":"Resource not found."}},"required":["code","message"]}},"required":["error"]}}}},"429":{"$ref":"#/components/responses/Error429"}}}}},"components":{"schemas":{"CreateCallRequest":{"type":"object","description":"POST /v1/calls — originate an outbound phone call that runs a VOICE template\n(IVR flow). The recipient is a raw phone number (no stored contact needed).","properties":{"to":{"type":"string","description":"The phone number to call, E.164 — e.g. \"+919876543210\".","maxLength":32},"template":{"type":"string","description":"The slug of the VOICE template (the IVR flow) to run on the call —\nthe same slug shown under Voice Templates. e.g. \"booking-ivr\".","maxLength":128},"variables":{"type":["array","null"],"description":"Template variable values that {{tokens}} in the flow resolve against —\na named object ({\"name\":\"Ravi\"}) or a positional array.","items":{"type":"string"}},"provider":{"type":["string","null"],"description":"Provider to place the call through. `omnio` (the default) routes via the\nmanaged Omnio Suite pool (no gateway to configure); otherwise a connected\nvoice slug — plivo_voice, twilio_voice, exotel_voice.","maxLength":64},"from":{"type":["string","null"],"description":"The number to show the person being called, in E.164 — one of YOUR\nassigned voice numbers. Omit it and a suitable one is picked\nautomatically; name it when you hold several and it matters which\nappears (two India numbers on different ranges, say, or a support line\nversus a sales line). A number you do not own, or one that cannot place\nthis particular call, is REFUSED rather than quietly swapped — being\nbilled for a call that went out under the wrong identity is worse than\nbeing told it could not go out at all.","maxLength":20},"idempotency_key":{"type":["string","null"],"description":"Your own id to make this idempotent — a retry with the SAME key replays\nthe original call instead of placing a second one.","maxLength":128},"correlation_id":{"type":["string","null"],"description":"Free cross-system reference, echoed back on the call + its webhooks.","maxLength":128}},"required":["to","template"],"title":"CreateCallRequest"},"CreateWhatsAppTemplateRequest":{"type":"object","description":"POST /v1/whatsapp/templates — create + submit a WhatsApp template to Meta for approval.\n\nThe body is the CANONICAL template shape (header/body/footer/buttons/samples/carousel/auth) —\nthe same structure the dashboard editor stores and that POST /v1/whatsapp/messages sends\nagainst — plus a raw Meta `components[]` escape hatch for component types the modelled fields\ndo not cover yet.\n\n  { \"name\": \"order_confirmed\", \"language\": \"en\", \"category\": \"UTILITY\",\n    \"header\":  { \"type\": \"image\", \"media_url\": \"https://…/banner.jpg\" },\n    \"body\":    \"Hi {{ name }}, your order {{ order_id }} is confirmed.\",\n    \"footer\":  \"Reply STOP to opt out\",\n    \"buttons\": [ { \"type\": \"url\", \"text\": \"Track\", \"value\": \"https://shop.test/{{ order_id }}\" } ],\n    \"samples\": { \"name\": \"Ravi\", \"order_id\": \"SO-2231\" } }\n\n`samples` gives every {{ placeholder }} an EXAMPLE value — Meta rejects a submission without\nthem. The real text substitution happens later, at SEND time, via `variables`; this is only\nthe reviewer's sample.","properties":{"name":{"type":["string","null"],"description":"The name Meta stores (lowercase_snake). Either this or display_name is required;\nit is normalised to Meta's charset (a-z0-9_) server-side.","maxLength":512},"display_name":{"type":["string","null"],"maxLength":191},"language":{"type":["string","null"],"description":"Meta language code — 'en' or 'en_US'. Stored/submitted as its base ('en').","maxLength":8},"category":{"type":["string","null"],"enum":["MARKETING","UTILITY","AUTHENTICATION"]},"from":{"type":["string","null"],"description":"Which connected number's WABA to submit against (E.164 or Meta phone_number_id).\nOmit to use the project's default WhatsApp number.","maxLength":64},"body":{"type":["string","null"],"maxLength":1024},"footer":{"type":["string","null"],"maxLength":60},"samples":{"type":["array","null"],"description":"Example values for {{ placeholders }} — { \"name\": \"Ravi\", … }. Meta requires one\nper variable or it rejects the submission.","items":{"type":"string"}},"components":{"type":["array","null"],"description":"ESCAPE HATCH — a raw Meta components[] array, converted to the stored shape as-is.\nA real JSON array, not an escaped string.","items":{"type":"string"}},"header":{"type":"object","properties":{"type":{"type":["string","null"],"enum":["none","text","image","video","document"]},"text":{"type":["string","null"],"maxLength":60},"media_url":{"type":["string","null"],"format":"uri","maxLength":2048}}},"carousel":{"type":"object","description":"MARKETING carousel — 2–10 media cards, each with its own header + buttons.","properties":{"cards":{"type":["array","null"],"items":{"type":"object","properties":{"header_type":{"type":["string","null"],"enum":["image","video"]},"media_url":{"type":["string","null"],"format":"uri","maxLength":2048},"body":{"type":["string","null"],"maxLength":160},"buttons":{"type":["array","null"],"items":{"type":"string"},"maxItems":2}}},"maxItems":10}}},"auth":{"type":"object","description":"AUTHENTICATION (OTP) knobs — Meta owns the body wording; these tune the extras.","properties":{"code_expiration_minutes":{"type":["integer","null"],"minimum":1,"maximum":90},"otp_button_text":{"type":["string","null"],"maxLength":25},"security_recommendation":{"type":["boolean","null"]}}},"buttons":{"type":["array","null"],"items":{"type":"object","properties":{"type":{"type":"string","enum":["url","quick_reply","phone","flow","catalog"]},"text":{"type":["string","null"],"maxLength":30},"value":{"type":["string","null"],"maxLength":2000},"flow_id":{"type":["string","null"],"maxLength":64},"flow_action":{"type":["string","null"],"maxLength":32},"navigate_screen":{"type":["string","null"],"maxLength":64}}},"maxItems":10}},"title":"CreateWhatsAppTemplateRequest"},"EmailSendRequest":{"type":"object","description":"POST /v1/emails — raw (template-less) email send. The caller supplies the subject and\nbody directly (no Templates entry), plus optional Cc / Bcc / Reply-To and base64 file\nattachments. Omit `from` to send from the default sender configured in the panel.\nStateless: no contact is stored, only a delivery log. Developer-surface keys only.","properties":{"to":{"type":"string","format":"email","description":"The primary recipient. Use `cc` / `bcc` for additional recipients."},"subject":{"type":"string","description":"The email subject line.","maxLength":998},"html":{"type":["string","null"],"description":"The body. Provide `html`, `text`, or both — at least one is required. With\nboth, the email is multipart/alternative (HTML + plain-text fallback)."},"text":{"type":["string","null"]},"from":{"type":["string","null"],"format":"email","description":"The verified address to send AS + its display name. OMIT to use the default\nsender configured in the panel (managed pool identity, or your connected\nprovider's from_email). A sender-scoped key must stay within its allow-list."},"from_name":{"type":["string","null"],"maxLength":100},"reply_to":{"type":["string","null"],"format":"email","description":"Where replies go, if different from `from`."},"provider":{"type":["string","null"],"description":"Provider routing (optional). `omnio` = the managed Omnio Suite pool;\na connected provider slug (ses, sendgrid, postmark, mailgun, brevo, resend…);\nor `provider_chain` for ordered failover. OMIT to use the default email\nprovider configured in the panel.","maxLength":64},"idempotency_key":{"type":["string","null"],"description":"Your own id to make this send idempotent — a retry with the SAME key\nreplays the original delivery log instead of sending twice.","maxLength":128},"wait":{"type":["boolean","null"],"description":"By default the email is ACCEPTED (202, status `queued`) and sent from a\nworker moments later — read the outcome on GET /v1/messages/{message_id}\nor the omnio.message.sent / .failed webhooks. Pass `wait: true` to hold the\nrequest until the gateway has accepted the message (201, status `sent`, or\n422 with the refusal) — slower, as it includes the gateway round-trips."},"correlation_id":{"type":["string","null"],"description":"Free cross-system reference, echoed back on the delivery log + webhooks.","maxLength":128},"cc":{"type":["array","null"],"description":"Carbon-copy / blind-carbon-copy recipients.","items":{"type":"string","format":"email"},"maxItems":50},"bcc":{"type":["array","null"],"items":{"type":"string","format":"email"},"maxItems":50},"attachments":{"type":["array","null"],"description":"File attachments. Each item is { filename, content, content_type? } where\n`content` is the file's bytes BASE64-encoded (a `data:<mime>;base64,…` URI\nis also accepted and unwrapped). Up to MAX_ATTACHMENTS files; their combined\ndecoded size must stay within MAX_TOTAL_BYTES. Only providers that support\nattachments (Amazon SES, SMTP) accept these — any other email provider\nrefuses the send rather than dropping the files.","items":{"type":"object","properties":{"filename":{"type":"string","maxLength":255},"content":{"type":"string"},"content_type":{"type":["string","null"],"maxLength":255}},"required":["filename","content"]},"maxItems":10},"provider_chain":{"type":["array","null"],"items":{"type":"string","maxLength":64},"minItems":1}},"required":["to","subject"],"title":"EmailSendRequest"},"InAppNotification":{"type":"object","properties":{"id":{"type":"string"},"tenant_id":{"type":"string"},"contact_id":{"type":"string"},"delivery_log_id":{"type":["string","null"]},"template_id":{"type":["string","null"]},"subject":{"type":["string","null"]},"body":{"type":"string"},"media_url":{"type":["string","null"]},"action_url":{"type":["string","null"]},"icon":{"type":["string","null"]},"category":{"type":["string","null"]},"metadata":{"type":"array","items":{}},"read_at":{"type":["string","null"],"format":"date-time"},"archived_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":["string","null"],"format":"date-time"},"updated_at":{"type":["string","null"],"format":"date-time"}},"required":["id","tenant_id","contact_id","delivery_log_id","template_id","subject","body","media_url","action_url","icon","category","metadata","read_at","archived_at","expires_at","created_at","updated_at"],"title":"InAppNotification"},"NotifyRequest":{"type":"object","description":"POST /v1/notify/{channel} — the Developer unified single-send. The channel is\nthe URL segment; the recipient is RAW (no stored contact): phone for\nsms/whatsapp/voice, email for email, a device token (+platform) for push. The\ncaller names the provider(s). DLT is always enforced — no skip_dlt field; and\nconsent is N/A for stateless sends — no skip_consent field.","properties":{"notification":{"type":"string","description":"The slug of the TEMPLATE to send (the same slug shown under Templates) —\nNOT the raw message text. Its channel version for {channel} is rendered\nwith `data`. e.g. \"wallet-recharge\".","maxLength":128},"data":{"type":["array","null"],"description":"Template variable values. Positional for DLT / WhatsApp — the Nth item\nfills {#var#} / var1, var2… e.g. [\"Ravi\",\"500\"]. A named object\n({\"name\":\"Ravi\",\"amount\":\"500\"}) also works.","items":{"type":"string"}},"provider":{"type":["string","null"],"description":"Provider slug to send through. Use `omnio` to route via the managed Omnio\nSuite pool (no own gateway needed — requires Omnio enabled for this channel);\notherwise a real slug of a provider you've CONNECTED (find it on the Providers\npage). Common built-ins — sms: msg91, twilio, gupshup, kaleyra, plivo, vonage,\nsinch; whatsapp: meta_cloud, gupshup, interakt, 360dialog, wati; email: ses,\nsendgrid, postmark, mailgun, brevo, resend; voice: exotel_voice, twilio_voice,\nplivo_voice; push: fcm, apns, onesignal. One of `provider` or `provider_chain`\nis REQUIRED (a raw recipient has no stored routing default).","maxLength":64},"idempotency_key":{"type":["string","null"],"description":"Your own id to make this send idempotent — a retry with the SAME key\nreplays the original delivery log instead of sending twice.","maxLength":128},"correlation_id":{"type":["string","null"],"description":"Free cross-system reference, echoed back on the delivery log + webhooks.","maxLength":128},"to":{"type":"array","description":"The RAW recipient (no stored contact). Shape depends on {channel}:\nsms / whatsapp / voice -> {\"phone\": \"+919876543210\"}\nemail                  -> {\"email\": \"user@example.com\"}\npush                   -> {\"token\": \"<device token>\", \"platform\": \"ios|android|web\"}","items":{"type":"string"},"minItems":1},"provider_chain":{"type":["array","null"],"description":"Ordered fallback list of provider slugs — tried in order, first success\nwins (exactly these, nothing appended). Use instead of `provider` for\nfailover, e.g. [\"msg91\",\"twilio\"].","items":{"type":"string","maxLength":64},"minItems":1}},"required":["notification","to"],"title":"NotifyRequest"},"RegisterDeviceRequest":{"type":"object","description":"Register/refresh a push device. Native platforms (ios/android) carry a `token`;\nweb carries a `subscription` object {endpoint, keys:{p256dh, auth}} from the\nbrowser's PushManager. Exactly one of the two is required per platform.","properties":{"platform":{"type":"string","description":"Device platform. Drives which provider delivers to it: ios→APNs, android→FCM, web→WebPush.","enum":["ios","android","web"]},"provider_slug":{"type":["string","null"],"description":"Optional provider override (fcm | apns | web_push | onesignal). Defaults from the platform.","maxLength":32},"provider_credential_id":{"type":["string","null"],"description":"The push credential (project) this token was minted against — the `id`\nreturned by GET /v1/sdk/config. Pins the device to that project so a\nmanaged send never routes the token to a different Firebase/APNs project.","maxLength":64},"token":{"type":["string","null"],"description":"Push token from a provider SDK (FCM / APNs / OneSignal). Present for native\nAND for web tokens (FCM-web / OneSignal-web are platform=web but token-based).\nExactly one of token / subscription is required — the raw Web Push (VAPID)\npath carries a subscription instead, so the requirement keys off the payload\n(not the platform), letting a web device register a token.","maxLength":512},"app_version":{"type":["string","null"],"description":"App version, for diagnostics (e.g. \"1.4.0\").","maxLength":32},"device_model":{"type":["string","null"],"description":"Device model (e.g. \"Pixel 8\", \"iPhone 15\").","maxLength":64},"os_version":{"type":["string","null"],"description":"OS version (e.g. \"Android 14\", \"iOS 17.2\").","maxLength":32},"install_id":{"type":["string","null"],"description":"Stable per-install id (a UUID your SDK generates once and persists). Feeds\nthe device fingerprint so a rotated push token updates the same device row\ninstead of creating a duplicate. Optional — dedup falls back to the token.","maxLength":64},"locale":{"type":["string","null"],"description":"BCP-47 locale of the device (e.g. \"en\", \"hi\"). Used for per-device language targeting.","maxLength":8},"timezone":{"type":["string","null"],"description":"IANA timezone of the device (e.g. \"Asia/Kolkata\"). Used for quiet-hours.","maxLength":64},"user_agent":{"type":["string","null"],"description":"Browser/user-agent string (web devices).","maxLength":512},"meta":{"type":["array","null"],"description":"Arbitrary key-value metadata stored with the device.","items":{"type":"string"}},"subscription":{"type":"object","description":"Raw Web Push (VAPID) subscription from the browser PushManager. Required only\nwhen there's no token (i.e. the provider is web_push, not FCM/OneSignal).","properties":{"endpoint":{"type":"string","description":"The push service endpoint URL from the browser subscription.","maxLength":512},"keys":{"type":"object","description":"The subscription's encryption key pair.","properties":{"p256dh":{"type":"string","description":"base64url-encoded ECDH P-256 public key (p256dh).","maxLength":255},"auth":{"type":"string","description":"base64url-encoded auth secret.","maxLength":255}}}}}},"required":["platform"],"title":"RegisterDeviceRequest"},"SendMessageRequest":{"type":"object","properties":{"template":{"type":"string","maxLength":128},"variables":{"type":["array","null"],"description":"Template values. Meta's NUMBERED placeholders ({{1}}, {{2}}) are supplied\npositionally — [\"Ravi\",\"SO-2231\"] or {\"1\":\"Ravi\",\"2\":\"SO-2231\"} — and\nNAMED ones ({{customer_name}}) as an object keyed by name. Both shapes are\nremapped onto the approved template's own variable order in SandeshService,\nso a caller never has to know which style the template was approved with.","items":{"type":"string"}},"force_channel":{"type":["string","null"],"enum":["sms","whatsapp","email","push","inapp","voice","webhook"]},"force_provider_credential_id":{"type":["string","null"],"format":"uuid"},"force_sender_id":{"type":["string","null"],"maxLength":64},"force_locale":{"type":["string","null"],"maxLength":8},"idempotency_key":{"type":["string","null"],"maxLength":128},"correlation_id":{"type":["string","null"],"maxLength":128},"skip_consent":{"type":["boolean","null"]},"to":{"type":"object","properties":{"contact_id":{"type":["string","null"],"format":"uuid"},"external_id":{"type":["string","null"],"maxLength":191},"email":{"type":["string","null"],"format":"email"},"phone":{"type":["string","null"],"maxLength":32}}}},"required":["template"],"title":"SendMessageRequest"},"SendWhatsAppRequest":{"type":"object","description":"POST /v1/whatsapp/messages — the dedicated WhatsApp API.\n\nDeliberately NOT an extension of /v1/messages. That endpoint is the portable,\nchannel-agnostic one (`to` + `template` + `variables`, same body for SMS, email and\npush) and it stays that way. WhatsApp needs structure no other channel has — media\nheaders, per-button runtime values, carousel cards — and bolting that onto the\nshared envelope would make every other channel's payload pay for it.\n\nThe shape follows META'S OWN IDIOM, which every serious WhatsApp API in the market\nconverges on: a `type` discriminator plus a sibling object NAMED AFTER that type.\nThat makes template-vs-session a structural choice rather than a validation rule —\nthe two cannot both be present, so there is no \"which one wins\" question. (Platforms\nthat allowed the ambiguity resolve it silently and surprise people: Brevo's `text`\nis dropped when a templateId is present, and Novu discards the authored body when a\ntemplate override is passed.)\n\n  TEMPLATE\n  { \"to\": \"+919876543210\", \"type\": \"template\",\n    \"template\": {\n      \"name\": \"order-shipped\",\n      \"variables\": { \"1\": \"Ravi\", \"2\": \"SO-2231\" },\n      \"header\":  { \"media\": { \"id\": \"med_01H…\" } },\n      \"buttons\": [ { \"ref\": \"track_order\", \"url_suffix\": \"orders/SO-2231\" } ]\n    } }\n\n  SESSION (free-form; only inside the 24h customer window)\n  { \"to\": \"+919876543210\", \"type\": \"document\",\n    \"document\": { \"media\": { \"url\": \"https://…/inv.pdf\" }, \"filename\": \"invoice.pdf\" } }\n\nVARIABLES — the keys of `template.variables` mirror the template's own placeholders:\n  • a NAMED placeholder {{customer_name}}  →  \"customer_name\": \"Ravi\"\n  • a NUMBERED placeholder {{1}}, {{2}}    →  \"1\": \"Ravi\", \"2\": \"SO-2231\"\n  • the SAME number in two components (a header {{1}} AND a body {{1}}) is the only\n    ambiguous case — scope that key by component: \"header.1\" / \"body.1\". Numbers that\n    live in just one component stay bare, so a header {{1}} + a body {{1}}…{{5}} is:\n      \"variables\": { \"header.1\": \"Avi\", \"body.1\": \"My company\",\n                     \"2\": \"AWB123\", \"3\": \"500\", \"4\": \"…\", \"5\": \"…\" }\nGET /v1/templates/{slug} lists the exact keys a template takes, so there is nothing to guess.\n\nTEXT SUBSTITUTION IS ALWAYS `variables`. Everything under `header`/`buttons`/\n`carousel` is what a text substitution cannot express — media, button runtime\nvalues, flow tokens, card structure. No component is addressable from two places.","properties":{"to":{"type":"string","description":"E.164 recipient. A bare string, not an object: this endpoint is\nsingle-channel, so there is nothing to disambiguate.","maxLength":32},"type":{"type":"string","enum":["template","text","image","video","audio","document","sticker"]},"from":{"type":["string","null"],"description":"Which of your connected numbers to send from (E.164 or its\nphone_number_id). Omit to use the project default — only needed once a\ntenant runs more than one number.","maxLength":64},"context_message_id":{"type":["string","null"],"description":"Quote an earlier message so it renders as a reply.","maxLength":256},"idempotency_key":{"type":["string","null"],"maxLength":128},"correlation_id":{"type":["string","null"],"maxLength":128},"template":{"type":"object","properties":{"name":{"type":"string","maxLength":128},"variables":{"type":["array","null"],"items":{"type":"string"}},"header":{"type":["object","null"],"properties":{"media":{"type":["object","null"],"properties":{"url":{"type":["string","null"],"format":"uri","maxLength":2048},"id":{"type":["string","null"],"maxLength":128},"filename":{"type":["string","null"],"maxLength":255}}}}},"buttons":{"type":["array","null"],"items":{"type":"object","properties":{"ref":{"type":"string","maxLength":128},"url_suffix":{"type":["string","null"],"maxLength":1024},"payload":{"type":["string","null"],"maxLength":256},"coupon_code":{"type":["string","null"],"maxLength":64},"flow_token":{"type":["string","null"],"maxLength":512},"product_retailer_id":{"type":["string","null"],"maxLength":128}}},"maxItems":10},"carousel":{"type":["array","null"],"items":{"type":"object","properties":{"header":{"type":["object","null"],"properties":{"media":{"type":["object","null"],"properties":{"url":{"type":["string","null"],"format":"uri","maxLength":2048},"id":{"type":["string","null"],"maxLength":128}}}}},"variables":{"type":["array","null"],"items":{"type":"string"}},"buttons":{"type":["array","null"],"items":{"type":"object","properties":{"ref":{"type":"string","maxLength":128},"url_suffix":{"type":["string","null"],"maxLength":1024},"payload":{"type":["string","null"],"maxLength":256}}},"maxItems":2}}},"maxItems":10},"components":{"type":["array","null"],"items":{"type":"string"}}},"required":["name"]}},"required":["to","type","template"],"title":"SendWhatsAppRequest"},"Error":{"type":"object","required":["error"],"properties":{"error":{"type":"object","required":["code"],"properties":{"code":{"type":"string","description":"Stable machine-readable code.","example":"not_found"},"message":{"type":"string","description":"Human-readable explanation.","example":"Resource not found."}}}}}},"responses":{"ValidationException":{"description":"Validation error","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","description":"Errors overview."},"errors":{"type":"object","description":"A detailed description of each field that failed validation.","additionalProperties":{"type":"array","items":{"type":"string"}}}},"required":["message","errors"]}}}},"ModelNotFoundException":{"description":"Not found","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","description":"Error overview."}},"required":["message"]}}}},"Error401":{"description":"Missing, invalid or revoked API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Error403":{"description":"The key is not allowed to do this (wrong surface, publishable key, or suspended account).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"Error429":{"description":"Too many requests. Retry after the number of seconds in `Retry-After`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"Secret API key from Dashboard → API keys, sent as `Authorization: Bearer sandesh_live_…`."}}},"security":[{"bearerAuth":[]}]}