OmniManager Docs

Resources

Sending messages, statuses and groups

Send/list WhatsApp messages, poll delivery state, post a status, list groups, and manage consented contacts.

Messages

MethodPathPermissionPurpose
POST/shops/{idOrName}/messagesmessages.sendqueue a WhatsApp message → 202
GET/shops/{idOrName}/messages?status=&limit=&cursor=messages.readlist, newest first
GET/messages/{id}messages.readone 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/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:

{ "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

MethodPathPermissionPurpose
POST/shops/{idOrName}/statusesstatuses.postpost 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

MethodPathPermissionPurpose
GET/shops/{idOrName}/groups?canSend=&minParticipants=&q=groups.readthe 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.

MethodPathPermissionPurpose
GET/shops/{idOrName}/contacts/consentedcontacts.readlist declared contacts
POST/shops/{idOrName}/contacts/consentedcontacts.writedeclare one or more numbers
DELETE/shops/{idOrName}/contacts/consentedcontacts.writerevoke 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"}]}'
FieldRule
phonerequired, E.164
noteoptional, 500 characters max
expiresAtoptional, 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.

LimitValueCode
Contacts per call50429 DECLARE_BATCH_TOO_LARGE
New declarations per tenant per UTC day200429 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=

ParameterDefaultNotes
activeOnlytruerevoked and expired rows are hidden unless you pass activeOnly=false
limit201-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.

MarkdownOpenAPI schema