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 |
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://console.omni-manager.com/api/v1/shops/SHOP_ID/messagesEvery 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:
{ "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 apply instead of the 1:1 ones.
Consented contacts
Yes — you can add numbers through the API. A key holding contacts.write may declare that a number is not a stranger, moving it from the cold bucket to declared, which has its own and usually larger daily budget (see Messaging & the anti-ban policy). Grant the permission per key in Settings → API.
| Method | Path | Permission | Purpose |
|---|---|---|---|
| GET | /shops/{idOrName}/contacts/consented | contacts.read | list declared contacts |
| POST | /shops/{idOrName}/contacts/consented | contacts.write | declare one or more numbers |
| DELETE | /shops/{idOrName}/contacts/consented | contacts.write | revoke one number |
The list is stored per tenant, not per shop: every shop of the same tenant shares one set of declarations, and shares the daily cap below too. The {idOrName} in the path only says which shop you are authenticating against.
Declaring numbers
POST is always a batch, even of one.
curl -X POST https://console.omni-manager.com/api/v1/shops/SHOP_ID/contacts/consented \
-H "Authorization: Bearer omk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"contacts":[{"phone":"+24177000000","note":"Asked us to write","expiresAt":"2027-01-01T00:00:00.000Z"}]}'| Field | Rule |
|---|---|
phone | required, E.164 |
note | optional, 500 characters max |
expiresAt | optional, ISO 8601 — once it passes the number silently returns to cold |
A malformed body is 422 VALIDATION_ERROR with a fields map naming each bad entry.
| Limit | Value | Code |
|---|---|---|
| Contacts per call | 50 | 429 DECLARE_BATCH_TOO_LARGE |
| New declarations per tenant per UTC day | 200 | 429 DECLARE_DAILY_CAP_REACHED |
Only rows that did not exist before count toward the daily total, but the check is made against the whole batch you send, so a batch that would cross the line is refused in full and nothing is written. Both limits are fixed platform constants, not tenant-governed knobs.
Reading the declare response
The response is per contact, not all-or-nothing. POST returns 202 with one entry per submitted number, in the order you sent them:
{"success":true,"data":{"items":[
{"phone":"+24177000000","ok":true,"contact":{"id":"…","phone":"+24177000000","source":"api","active":true,"expiresAt":null,"revokedAt":null,"createdAt":"…"}},
{"phone":"+24177000001","ok":false,"code":"RECIPIENT_OPTED_OUT","message":"…"}
]}}The status is 422 only when every entry failed. Always read items[].ok — a 202 does not mean they all landed.
Re-declaring a number you already declared is an update, not an error. It refreshes note and expiresAt, clears a previous DELETE revoke, and does not consume one of the 200 daily new-declaration slots.
Every declaration is written to the audit log as contact.declare, attributed to the API key by name, and always records source: "api" — the value cannot be set by the caller.
Listing declared contacts
GET /shops/{idOrName}/contacts/consented?activeOnly=&limit=&cursor=
| Parameter | Default | Notes |
|---|---|---|
activeOnly | true | revoked and expired rows are hidden unless you pass activeOnly=false |
limit | 20 | 1-100 |
cursor | — | pass back nextCursor from the previous response; it is absent on the last page |
Each row carries id, phone, source, note, expiresAt, revokedAt, createdAt, and active — a computed field meaning not revoked and not past expiresAt.
Revoking
DELETE takes { "phone": "+241…" } and returns { "phone", "revokedAt" }. It sets revokedAt and never hard-deletes, so the audit trail stays. A number that was never declared returns 404 CONTACT_NOT_FOUND.
Opt-out always outranks a declaration
A number that replies STOP / ARRÊT / DÉSABONNER / UNSUBSCRIBE opts out whether or not it was ever declared — the next send to it fails 422 RECIPIENT_OPTED_OUT until it writes in again. Declaring it while it is opted out fails with that same code, per contact, rather than resurrecting it.