# OmniManager Public API — full reference One file, every published article. Generated from the live route code — see /openapi.json for the machine-readable schema. --- # Getting started with the OmniManager API > Authentication, the response envelope, permissions and rate limits shared by every /api/v1 route. # Getting started The OmniManager public API (`/api/v1/**`) gives programmatic access to a tenant's account with a per-tenant API key: shops, WhatsApp connection and sending, statuses, groups, and consent management — with the platform's anti-ban policy enforced on your behalf on every send. Base URL: `https:///api/v1` ## Authentication Create a key in **Settings → API** (requires `canEditSettings`), or ask an OmniManager admin to mint one for your tenant. The raw key (`omk_live_…`) is shown once; only its SHA-256 hash is stored, so if you lose it you must rotate, not "recover" it. Send it on every request: ``` Authorization: Bearer omk_live_YOUR_KEY ``` (`x-api-key: omk_live_YOUR_KEY` is accepted as an alias.) A key can hold an expiry and an IP allowlist; revoking a key (from Settings or the admin) takes effect immediately — the next request with that key fails `401 API_KEY_REVOKED`. ## Permissions Every route requires one permission from this list (`src/libs/api-keys/permissions.ts`); a key only has the ones it was granted: | Permission | Grants | |---|---| | `shops.read` | list/read shops | | `shops.write` | create shops | | `whatsapp.read` | read WhatsApp channel state | | `whatsapp.connect` | start/poll/stop the WhatsApp connection | | `messages.send` | send WhatsApp messages | | `messages.read` | read message/delivery state | | `statuses.post` | post a WhatsApp status | | `contacts.read` | list declared (consented) contacts | | `contacts.write` | declare / revoke consented contacts | | `groups.read` | list the shop's WhatsApp groups | `GET /me` needs none of these — a key with a single permission can still verify itself. ## Envelope Every response is one of two shapes: ```jsonc // success { "success": true, "data": { /* … */ } } // error { "success": false, "error": { "code": "DAILY_CAP_REACHED", "message": "…", "retryAfter": 3600 } } ``` Every response carries `X-Request-Id` (echoed back if you send one) and, once authenticated, `X-RateLimit-Limit / Remaining / Reset / Scope` for the bucket that request just consumed. ### Common error codes | HTTP | code | meaning | |---|---|---| | 401 | `API_KEY_MISSING` / `API_KEY_INVALID` / `API_KEY_REVOKED` / `API_KEY_EXPIRED` | key problems | | 402 / 429 | `QUOTA_EXCEEDED` | plan quota or capability spent | | 403 | `IP_NOT_ALLOWED` / `TENANT_INACTIVE` / `FORBIDDEN_PERMISSION` | access denied | | 404 | `SHOP_NOT_FOUND` / `MESSAGE_NOT_FOUND` / `DOC_ARTICLE_NOT_FOUND` | | | 409 | `AMBIGUOUS_SHOP` | several shops share that name — pass the id instead | | 409 | `WHATSAPP_NOT_CONFIGURED` | `GET` connect before any `POST` connect | | 422 | `VALIDATION_ERROR` | body/query invalid (`error.fields`) | | 429 | `RATE_LIMITED` | per-key minute/hour/day limit (`Retry-After`) | | 429 | anti-ban policy codes | see [Messaging & the anti-ban policy](messaging-policy) | | 503 | `BOT_SERVICE_UNREACHABLE` / `WHATSAPP_NOT_CONNECTED` / `WHATSAPP_SESSION_LOST` | session / bot down | ## Rate limits and metering Per key, by default: 60/min, 1 000/h, 10 000/day (see `X-RateLimit-*` headers on every response; a breach returns `429 RATE_LIMITED` with `Retry-After`). Every call counts toward the plan's `api_calls` allowance, visible in `GET /me`; every request is logged (route template, status, latency, IP — never the request body) and visible to OmniManager admins. ## First call ```bash curl -s -H "Authorization: Bearer omk_live_YOUR_KEY" https://app.omni-manager.com/api/v1/me ``` returns your key's permissions, rate limits and remaining `api_calls` quota. --- # Messaging & the anti-ban policy > The caps, recipient classes and error codes evaluateSendPolicy enforces on every WhatsApp send. # Messaging & the anti-ban policy A shop's WhatsApp is a real phone number on WhatsApp Web; WhatsApp bans numbers that behave like spam. `evaluateSendPolicy` (`src/libs/services/WhatsAppSendPolicy.ts`) refuses on the tenant's behalf before anything is sent, on every route that can put a message on the wire — not only the API. ## Recipient classes Every 1:1 send is classified before the caps below are checked: | Class | Meaning | |---|---| | `known` | matches a `CustomerModel` row, or the number has written to the shop before | | `declared` | not known, but the tenant has declared it via `POST …/contacts/consented` and the declaration is still active (not revoked, not expired) | | `cold` | neither of the above — a genuine stranger | | `group` | the recipient is a WhatsApp group (`@g.us`), never known/declared/cold | ## Caps (1:1) | Rule | Default | Code | |---|---|---| | Content | ≤ 4096 chars, ≤ 1 media, text or media required | `MESSAGE_TOO_LONG` / `TOO_MANY_MEDIA` / `EMPTY_MESSAGE` | | Opt-out | recipient's last inbound was STOP/ARRÊT/DÉSABONNER/UNSUBSCRIBE | `RECIPIENT_OPTED_OUT` | | Warm-up ramp (days since the WhatsApp session connected) | d0–2: 20/day · d3–6: 50 · d7–13: 100 · d14+: 250 | `DAILY_CAP_REACHED` | | Hourly | max(10, daily ÷ 6) | `HOURLY_CAP_REACHED` | | Cold recipients | 10% of the daily cap, minimum 5 | `COLD_RECIPIENT_CAP_REACHED` | | Declared recipients | its own, tenant-configurable budget — separate from cold's, does not spill over into it | `DECLARED_RECIPIENT_CAP_REACHED` | | Per recipient | ≤ 3 messages / 24h, ≥ 10 min apart unless they replied since | `RECIPIENT_RATE_LIMITED` | | Bulk pattern | identical text to more than 20 distinct numbers within 1h | `BULK_PATTERN_BLOCKED` | Tenants can tune `coldPerDay`/`declaredPerDay` and related knobs within a platform-configured floor and roof from **Settings → Messaging**; a value outside [floor, roof] is rejected server-side, never merely clamped silently. ## Caps (groups) Sending to a group uses an entirely separate budget from the 1:1 one, keyed on `groupsPerDay`/`groupPerRecipient24h`, because a group is not a person: | Rule | Code | |---|---| | The caller must supply group context (participant count, admin status, announce flag) | `GROUP_CONTEXT_REQUIRED` | | The shop's number must administer the group — required for every group, not only "announce" ones, since opt-out cannot work per-member and mere membership is not consent to broadcast | `GROUP_NOT_ADMINISTERED` | | Weighted daily cap: one "share" of `groupsPerDay` per `GROUP_SIZE_UNIT` participants (rounded up, minimum 1) — a 200-member group costs more than a 5-member one, because it reaches more people | `GROUP_DAILY_CAP_REACHED` | | Per group | ≤ `groupPerRecipient24h` messages / 24h | `RECIPIENT_RATE_LIMITED` | `GET /shops/{idOrName}/groups` returns `isAdmin`/`announce` per group so a caller can filter and explain a likely denial before attempting a send — but the policy above is what is actually authoritative; the route's `canSend` field is advisory. ## Enforced inside the bot regardless of caller The recipient must exist on WhatsApp (`rejected`, code `RECIPIENT_NOT_ON_WHATSAPP`); one send at a time per shop with a random 6–20 second gap between sends; a "composing" (typing) presence shown before each message; a 15-minute pause of the shop's whole queue on a rate signal from WhatsApp itself; and on a lost session every queued message fails `WHATSAPP_SESSION_LOST` and the channel is flagged until reconnected. If a shop's own WhatsApp is disconnected, some sends may route through the platform's shared pool instead, wrapped with a visible "sent via OmniManager" attribution — never silently as the tenant. Best practice: message people who expect it, vary wording, spread sends over the day, and honour opt-outs and replies immediately. --- # Shops > List, create and fetch a tenant's shops. # Shops `{idOrName}` in every route below accepts either the shop's ObjectId or its exact name (case-insensitive) — URL-encode the name when it contains spaces (`Shop%20Name`). Two shops sharing a name resolves to `409 AMBIGUOUS_SHOP` with the candidate ids listed; use the id from then on. | Method | Path | Permission | Purpose | |---|---|---|---| | GET | `/shops?page=&limit=` | `shops.read` | paginated list, newest first | | POST | `/shops` | `shops.write` | create with the bare minimum | | GET | `/shops/{idOrName}` | `shops.read` | one shop | ## Create a shop `POST /shops` takes only `{ name, phone? }` — everything else (coordinates, opening hours, pickup points, channels) is completed in the dashboard. The response is `201` with a `setupUrl` that opens the shop's edit form directly, and `setupRequired: ["location", "operatingHours"]` naming what is still missing. ```bash curl -s -X POST -H "Authorization: Bearer omk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"SHOP_NAME","phone":"+24177000000"}' \ https://app.omni-manager.com/api/v1/shops ``` Fails `402 QUOTA_EXCEEDED` at the plan's shop limit, `409 SHOP_NAME_TAKEN` on a duplicate name, and `409 TENANT_OWNER_MISSING` if the tenant has no owner user on record (create it from the dashboard instead in that case). --- # Connecting a shop's WhatsApp > QR-code connect flow, polling, and disconnecting a shop's WhatsApp session. # Connecting a shop's WhatsApp | Method | Path | Permission | Purpose | |---|---|---|---| | GET | `/shops/{idOrName}/channels/whatsapp` | `whatsapp.read` | stored channel state + live session | | POST | `/shops/{idOrName}/channels/whatsapp/connect` | `whatsapp.connect` | start connecting; returns a QR or `connected` | | GET | `/shops/{idOrName}/channels/whatsapp/connect` | `whatsapp.connect` | poll until `connected` (latest QR each call) | | POST | `/shops/{idOrName}/channels/whatsapp/disconnect` | `whatsapp.connect` | stop the session, keep the configuration | ## Flow ``` POST …/channels/whatsapp/connect → { "status": "qr_waiting", "qr": "", "qrImageDataUrl": "data:image/png;base64,…", "expiresAt": "…", "pollAfterMs": 3000, "channelConfigId": "…" } GET …/channels/whatsapp/connect (every pollAfterMs) → { "status": "qr_waiting", … } (the QR rotates every ~20–60s — always show the latest) → { "status": "connected", "phone": "24177000000", … } ``` Other statuses: `starting` (the bot is booting — poll again) and `disconnected` (the session dropped — `POST connect` again). Polling before any `POST` returns `409 WHATSAPP_NOT_CONFIGURED`. Once `connected` arrives the API persists exactly what the dashboard's own QR modal does (channel state, the shop's `channelStatus.whatsapp`, the owner notification number). The WhatsApp session credentials themselves are stored encrypted by the bot the instant the phone scans the code — they never transit this API. ## Disconnect `POST …/channels/whatsapp/disconnect` stops the live session but keeps the channel's configuration, so a later `connect` reuses it (and its quota slot) rather than starting over. Returns `404 WHATSAPP_NOT_CONFIGURED` if the shop never had a WhatsApp channel at all. --- # Sending messages, statuses and groups > Send/list WhatsApp messages, poll delivery state, post a status, list groups, and manage consented contacts. # Sending messages, statuses and groups ## Messages | Method | Path | Permission | Purpose | |---|---|---|---| | POST | `/shops/{idOrName}/messages` | `messages.send` | queue a WhatsApp message → `202` | | GET | `/shops/{idOrName}/messages?status=&limit=&cursor=` | `messages.read` | list, newest first | | GET | `/messages/{id}` | `messages.read` | one message's delivery state | ```bash curl -s -X POST -H "Authorization: Bearer omk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"channel":"whatsapp","to":"+24177000000","text":"Hello!", "mediaUrl":"https://example.com/photo.jpg","mediaType":"image", "idempotencyKey":"order-1234-confirmation"}' \ https://app.omni-manager.com/api/v1/shops/SHOP_ID/messages ``` Every field but `channel` and `to` is optional; either `text` or `mediaUrl` is required (`422 EMPTY_MESSAGE` otherwise). `to` must be E.164 (`+CCxxxxxxxxx`). At most one `mediaUrl` per call — this endpoint's contract is one attachment, always. Repeating a call with the same `idempotencyKey` returns the original message (`200`, header `X-Idempotent-Replay: true`) and never sends twice. A successful call is `202` with the queued message and the policy verdict that let it through: ```jsonc { "id": "…", "status": "queued", "statusUrl": "/api/v1/messages/…", "policy": { "recipientClass": "known", // "known" | "cold" | "declared" | "group" "warmupDay": 12, "caps": { "perDay": 100, "perHour": 16, "coldPerDay": 10, "declaredPerDay": 50 }, "usage": { "today": 3, "lastHour": 1, "coldToday": 0, "declaredToday": 0 } } } ``` Poll `GET /messages/{id}` for the terminal state: `sent`, `failed` (delivery error — `BOT_RESTARTED`, `WHATSAPP_SESSION_LOST`, `BOT_SERVICE_UNREACHABLE`, or `WHATSAPP_NOT_CONNECTED` once a message has waited too long for a session that never came back), or `rejected` (`RECIPIENT_NOT_ON_WHATSAPP`) — a message never stays `queued` forever. ## Statuses | Method | Path | Permission | Purpose | |---|---|---|---| | POST | `/shops/{idOrName}/statuses` | `statuses.post` | post a WhatsApp status through the shop's number → `202` | Body: `{ caption?, mediaUrl?, mediaType? }` ("image" or "video"; either `caption` or `mediaUrl` required, `caption` capped at ~700 characters, one attachment). Subject to its own daily cap, `statusesPerDay`, distinct from the message caps above — `429 STATUSES_DAILY_CAP_REACHED` once spent. ## Groups | Method | Path | Permission | Purpose | |---|---|---|---| | GET | `/shops/{idOrName}/groups?canSend=&minParticipants=&q=` | `groups.read` | the shop's WhatsApp groups | Each item is `{ id, name, participantCount, isAdmin, announce, canSend }`. `canSend` is exactly `isAdmin` — not `!announce || isAdmin` — because opt-out cannot work per-member inside a group and mere membership is not evidence of consent to broadcast; administering the group is the only signal available, so it is required to send to ANY group, not only "announce" ones. Filter with `?canSend=true`, `?minParticipants=50`, `?q=name substring`. Sending to a group is the same `POST …/messages` above with a group JID as `to`; `recipientClass` in the policy verdict is then `"group"`, and the group-specific caps in the [anti-ban policy article](messaging-policy) apply instead of the 1:1 ones. ## Consented contacts A tenant can declare that a number is not a stranger, moving it from the `cold` bucket to `declared` (its own, usually larger, daily budget — see [Messaging & the anti-ban policy](messaging-policy)). | Method | Path | Permission | Purpose | |---|---|---|---| | GET | `/shops/{idOrName}/contacts/consented?activeOnly=&limit=&cursor=` | `contacts.read` | list declared contacts (not shop-scoped in storage — every tenant shop sees the same list) | | POST | `/shops/{idOrName}/contacts/consented` | `contacts.write` | declare one or more numbers | | DELETE | `/shops/{idOrName}/contacts/consented` | `contacts.write` | revoke one number | `POST` is always a batch, even of one: `{ "contacts": [{ "phone": "+241…", "note"?, "expiresAt"? }] }`. Capped at a batch size and at a genuinely-new-declarations-per-day ceiling (both server-side, not tenant-governed); exceeding either returns `429 DECLARE_BATCH_TOO_LARGE` / `429 DECLARE_DAILY_CAP_REACHED` without touching the ones that would have fit. A declaration made through this route always records `source: "api"`. `DELETE` takes `{ "phone": "+241…" }` and revokes (`revokedAt` set), never hard-deletes — the audit trail stays. A number that replies `STOP` / `ARRÊT` / `DÉSABONNER` / `UNSUBSCRIBE` opts out regardless of whether it was ever declared — the next send to it fails `422 RECIPIENT_OPTED_OUT` until it writes in again.