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