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 |
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/messagesTous 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 :
{ "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 s'appliquent à la place des plafonds 1:1.
Contacts consentants
Oui, vous pouvez ajouter des numéros via l'API. Une clé disposant de contacts.write peut déclarer qu'un numéro n'est pas un inconnu, le faisant passer du compartiment cold au compartiment declared, qui a son propre budget quotidien, généralement plus large (voir Messagerie et politique anti-bannissement). La permission s'accorde clé par clé dans Paramètres → API.
| Méthode | Chemin | Permission | Objet |
|---|---|---|---|
| GET | /shops/{idOrName}/contacts/consented | contacts.read | lister les contacts déclarés |
| 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 |
La liste est stockée par tenant, pas par boutique : toutes les boutiques d'un même tenant partagent les mêmes déclarations, et partagent aussi le plafond quotidien ci-dessous. Le {idOrName} du chemin indique seulement la boutique auprès de laquelle vous vous authentifiez.
Déclarer des numéros
POST est toujours un lot, même d'un seul élément.
curl -X POST https://console.omni-manager.com/api/v1/shops/ID_BOUTIQUE/contacts/consented \
-H "Authorization: Bearer omk_live_VOTRE_CLE" \
-H "Content-Type: application/json" \
-d '{"contacts":[{"phone":"+24177000000","note":"Nous a demandé de lui écrire","expiresAt":"2027-01-01T00:00:00.000Z"}]}'| Champ | Règle |
|---|---|
phone | obligatoire, format E.164 |
note | facultatif, 500 caractères maximum |
expiresAt | facultatif, ISO 8601 — une fois passé, le numéro redevient cold sans avertissement |
Un corps invalide renvoie 422 VALIDATION_ERROR avec une carte fields nommant chaque entrée fautive.
| Limite | Valeur | Code |
|---|---|---|
| Contacts par appel | 50 | 429 DECLARE_BATCH_TOO_LARGE |
| Nouvelles déclarations par tenant et par jour UTC | 200 | 429 DECLARE_DAILY_CAP_REACHED |
Seules les lignes qui n'existaient pas comptent dans le total quotidien, mais la vérification porte sur le lot entier que vous envoyez : un lot qui ferait franchir la limite est refusé en totalité et rien n'est écrit. Les deux limites sont des constantes de plateforme, pas des réglages pilotés par le tenant.
Lire la réponse de déclaration
La réponse est par contact, pas tout ou rien. POST renvoie 202 avec une entrée par numéro soumis, dans l'ordre d'envoi :
{"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":"…"}
]}}Le statut n'est 422 que si toutes les entrées ont échoué. Lisez toujours items[].ok : un 202 ne signifie pas que tout est passé.
Redéclarer un numéro déjà déclaré est une mise à jour, pas une erreur. Cela rafraîchit note et expiresAt, annule une révocation DELETE antérieure, et ne consomme aucun des 200 créneaux quotidiens de nouvelle déclaration.
Chaque déclaration est journalisée sous contact.declare, attribuée à la clé API par son nom, et enregistre toujours source: "api" — cette valeur ne peut pas être fixée par l'appelant.
Lister les contacts déclarés
GET /shops/{idOrName}/contacts/consented?activeOnly=&limit=&cursor=
| Paramètre | Défaut | Remarques |
|---|---|---|
activeOnly | true | les lignes révoquées ou expirées sont masquées sauf si vous passez activeOnly=false |
limit | 20 | de 1 à 100 |
cursor | — | renvoyez le nextCursor de la réponse précédente ; il est absent sur la dernière page |
Chaque ligne porte id, phone, source, note, expiresAt, revokedAt, createdAt, et active — un champ calculé signifiant « ni révoqué, ni expiré ».
Révoquer
DELETE prend { "phone": "+241…" } et renvoie { "phone", "revokedAt" }. Il renseigne revokedAt sans jamais supprimer définitivement, la piste d'audit reste. Un numéro jamais déclaré renvoie 404 CONTACT_NOT_FOUND.
Le désabonnement prime toujours sur la déclaration
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. Le déclarer pendant qu'il est désabonné échoue avec ce même code, contact par contact, plutôt que de le ressusciter.