# Envoyer des messages, statuts et groupes

> Envoyer/lister les messages WhatsApp, interroger l'état de livraison, publier un statut, lister les groupes, et gérer les contacts consentants.

# Envoyer des messages, statuts et groupes

## Messages

| Méthode | Chemin | Permission | Objet |
|---|---|---|---|
| POST | `/shops/{idOrName}/messages` | `messages.send` | mettre en file un message WhatsApp → `202` |
| GET | `/shops/{idOrName}/messages?status=&limit=&cursor=` | `messages.read` | liste, les plus récents d'abord |
| GET | `/messages/{id}` | `messages.read` | état de livraison d'un message |

```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
```

Tous les champs sauf `channel` et `to` sont optionnels ; `text` ou
`mediaUrl` est requis (sinon `422 EMPTY_MESSAGE`). `to` doit être au
format E.164 (`+CCxxxxxxxxx`). Au plus un `mediaUrl` par appel — le
contrat de cet endpoint est toujours une seule pièce jointe. Répéter un
appel avec le même `idempotencyKey` renvoie le message d'origine (`200`,
en-tête `X-Idempotent-Replay: true`) et n'envoie jamais deux fois.

Un appel réussi renvoie `202` avec le message mis en file et le verdict de
la politique qui l'a laissé passer :

```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 }
  } }
```

Interrogez `GET /messages/{id}` pour l'état final : `sent`, `failed`
(erreur de livraison — `BOT_RESTARTED`, `WHATSAPP_SESSION_LOST`,
`BOT_SERVICE_UNREACHABLE`, ou `WHATSAPP_NOT_CONNECTED` une fois qu'un
message a trop longtemps attendu une session qui n'est jamais revenue), ou
`rejected` (`RECIPIENT_NOT_ON_WHATSAPP`) — un message ne reste jamais
`queued` indéfiniment.

## Statuts

| Méthode | Chemin | Permission | Objet |
|---|---|---|---|
| POST | `/shops/{idOrName}/statuses` | `statuses.post` | publier un statut WhatsApp via le numéro de la boutique → `202` |

Corps : `{ caption?, mediaUrl?, mediaType? }` ("image" ou "video" ;
`caption` ou `mediaUrl` requis, `caption` plafonné à ~700 caractères, une
seule pièce jointe). Soumis à son propre plafond quotidien,
`statusesPerDay`, distinct des plafonds de messages ci-dessus —
`429 STATUSES_DAILY_CAP_REACHED` une fois épuisé.

## Groupes

| Méthode | Chemin | Permission | Objet |
|---|---|---|---|
| GET | `/shops/{idOrName}/groups?canSend=&minParticipants=&q=` | `groups.read` | les groupes WhatsApp de la boutique |

Chaque élément est `{ id, name, participantCount, isAdmin, announce,
canSend }`. `canSend` vaut exactement `isAdmin` — pas `!announce ||
isAdmin` — parce que le désabonnement ne peut pas fonctionner par membre à
l'intérieur d'un groupe et la simple appartenance n'est pas une preuve de
consentement à la diffusion ; administrer le groupe est le seul signal
disponible, il est donc requis pour envoyer à N'IMPORTE QUEL groupe, pas
seulement ceux en mode « annonces ». Filtrez avec `?canSend=true`,
`?minParticipants=50`, `?q=sous-chaîne du nom`.

Envoyer à un groupe se fait avec le même `POST …/messages` ci-dessus, avec
un JID de groupe comme `to` ; `recipientClass` dans le verdict de la
politique vaut alors `"group"`, et les plafonds spécifiques aux groupes de
[la politique anti-bannissement](messaging-policy) s'appliquent à la place
des plafonds 1:1.

## Contacts consentants

Un tenant peut déclarer qu'un numéro n'est pas un inconnu, le faisant
passer du compartiment `cold` au compartiment `declared` (son propre
budget quotidien, généralement plus large — voir
[Messagerie et politique anti-bannissement](messaging-policy)).

| Méthode | Chemin | Permission | Objet |
|---|---|---|---|
| GET | `/shops/{idOrName}/contacts/consented?activeOnly=&limit=&cursor=` | `contacts.read` | lister les contacts déclarés (non isolés par boutique en stockage — chaque boutique du tenant voit la même liste) |
| POST | `/shops/{idOrName}/contacts/consented` | `contacts.write` | déclarer un ou plusieurs numéros |
| DELETE | `/shops/{idOrName}/contacts/consented` | `contacts.write` | révoquer un numéro |

`POST` est toujours un lot, même d'un seul élément :
`{ "contacts": [{ "phone": "+241…", "note"?, "expiresAt"? }] }`.
Plafonné à une taille de lot et à un plafond de déclarations réellement
nouvelles par jour (les deux côté serveur, non pilotés par le tenant) ;
dépasser l'un ou l'autre renvoie `429 DECLARE_BATCH_TOO_LARGE` /
`429 DECLARE_DAILY_CAP_REACHED` sans toucher à celles qui seraient passées.
Une déclaration faite via cette route enregistre toujours `source: "api"`.
`DELETE` prend `{ "phone": "+241…" }` et révoque (`revokedAt` renseigné),
sans jamais supprimer définitivement — la piste d'audit reste.

Un numéro qui répond `STOP` / `ARRÊT` / `DÉSABONNER` / `UNSUBSCRIBE`
se désabonne, qu'il ait été déclaré ou non — l'envoi suivant vers lui
échoue avec `422 RECIPIENT_OPTED_OUT` tant qu'il n'écrit pas à nouveau.
