# Guide LLM — intégrer SMSMarocPro

Ce guide est destiné aux développeurs et aux assistants de code. Il décrit l’API publique disponible, sans dépendance à votre framework.

- Origine API : `https://smsmarocpro.com` (sans barre finale).
- Documentation : https://smsmarocpro.com/developers
- Contrat machine faisant autorité : https://smsmarocpro.com/api/openapi
- Exemple Node.js téléchargeable : https://smsmarocpro.com/examples/smsmarocpro.mjs
- Clés et destinations webhook : https://smsmarocpro.com/app/developers

## 1. Configuration exclusivement sur le serveur

Créer une clé avec `messages:write` et `messages:read`. Stocker `SMSMAROCPRO_API_KEY` et `SMSMAROCPRO_WEBHOOK_SECRET` dans les secrets du serveur. Le secret webhook est distinct de la clé API ; ne pas le décoder en Base64. Ne jamais transmettre ces secrets au navigateur ou les journaliser.

Ajouter `usage:read` si l’intégration lit `GET /v1/availability` ou `GET /v1/usage` pour vérifier le quota. Les campagnes nécessitent `campaigns:write` et `campaigns:read` ; les imports nécessitent `contacts:write` et `contacts:read`. Pour enregistrer les oppositions, utiliser `suppressions:write` (`suppressions:read` pour les consulter). Une clé limitée aux messages ne donne pas automatiquement accès à ces autres opérations.

Les exemples effectuent des envois réels : il faut un forfait actif, du crédit et un destinataire autorisé. Le simulateur « Avant l’envoi » de l’espace développeur ne transmet aucun SMS et ne valide pas les autorisations du compte.

## 2. Une opération métier = une clé d’idempotence persistée

Avant le premier appel, enregistrer dans votre base la clé, le corps exact et la référence métier. Exemple : `commande_2026_000001_ready`. La clé comporte 16 à 100 caractères parmi lettres, chiffres, `_` et `-`.

```sh
curl --request POST 'https://smsmarocpro.com/v1/messages' \
  --header "Authorization: Bearer $SMSMAROCPRO_API_KEY" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: commande_2026_000001_ready' \
  --data '{"to":"+2126XXXXXXXX","trafficClass":"transactional","text":"Votre commande est prête."}'
```

Remplacer le numéro masqué par un numéro marocain au format E.164 (`+212` puis neuf chiffres, commençant par 5, 6 ou 7). `trafficClass` vaut `transactional` pour un SMS individuel. Ne pas inventer un champ `templateId` : transmettre le texte final. Limite du corps JSON : 64 Kio. Deux segments maximum par message.

Première acceptation : HTTP **202**, corps comprenant `id`, `state`, `quotaUnits`, `createdAt`. Rejeu exact de la même opération : HTTP **200**, même identifiant. Ces réponses ne confirment pas encore l’envoi. Sauvegarder l’identifiant `msg_…` avec votre opération métier.

Après timeout, coupure réseau ou 503, ne pas créer une nouvelle clé. La demande initiale peut avoir été acceptée. Préférer la récupération **en lecture seule** ci-dessous avant toute décision de rejeu. Un contenu différent avec la même clé retourne 409. L’exemple Node.js ne réessaie jamais automatiquement.

### Retrouver une acceptation dont la réponse a été perdue

`GET /v1/message-operations`, scope `messages:read`, avec la **clé d’envoi originale** dans l’en-tête `Idempotency-Key`, sans corps ni paramètres. Le helper téléchargeable est `getMessageOperation({ apiKey, operationKey })`. Cette lecture ne crée aucun message, tentative ou réservation et ne reprend aucun workflow arrêté. Seuls les compteurs habituels d’authentification/limitation et la télémétrie peuvent évoluer.

HTTP 200 retourne `operation: messages.create`, `messageId`, `acceptedAt`, `requestHashAlgorithm: sha256-json-stringify-v1`, `requestHash` et `message` (ressource sûre courante, ou null si indisponible). Avant de rattacher cet identifiant, comparer `requestHash` au SHA-256 de `JSON.stringify` du corps original tel qu’il a été parsé : mêmes champs, valeurs et ordre, sans reconstruction ni réécriture. Le serveur conserve ses règles d’idempotence existantes. Une autre clé active du même compte avec `messages:read` peut retrouver cette opération ; aucun abonnement d’envoi actif n’est requis.

HTTP 404 `OPERATION_NOT_FOUND` signifie qu’aucun enregistrement retenu n’est visible à cet instant. Il ne prouve pas que le SMS n’a jamais été accepté : le POST peut encore être en cours ou la rétention de l’opération (30 jours) peut avoir expiré. Un 429/503 ou timeout est une indisponibilité temporaire, jamais une absence. **Ne pas transformer 404 en POST automatique**, particulièrement après arrêt du workflow ou expiration de `sendBy`. Pour un nouvel envoi sensible au temps, persister dès le départ un `sendBy` explicite ; ne jamais modifier le corps historique pour tenter de récupérer son identité.

## 3. Suivre le même message

```sh
curl 'https://smsmarocpro.com/v1/messages/msg_REMPLACER' \
  --header "Authorization: Bearer $SMSMAROCPRO_API_KEY"
```

| `state` | Interprétation côté application |
|---|---|
| `accepted`, `ready`, `scheduled` | En attente / programmé |
| `dispatching`, `submitted` | Transmission en cours / demande soumise |
| `sent` | Envoyé, réception non confirmée |
| `delivered` | Réception confirmée, sans preuve de lecture |
| `reconciling`, `manual_review` | Résultat incertain : vérifier sans recréer le SMS |
| `failed`, `expired`, `cancelled` | Échec / expiration / annulation |

Les identifiants d’état et d’événement sont stables : ne pas traduire les valeurs JSON. Dans l’interface, afficher « Réception confirmée » pour `delivered`. L’absence de cet état ne prouve pas que le destinataire n’a rien reçu.

Le suivi et la récupération d’une même opération ne nécessitent pas une validation administrateur à chaque appel. Rejouer la clé et le corps d’origine retrouve le même SMS, sans nouvelle transmission. Les échecs établis sont traités automatiquement ; si le résultat physique reste indéterminé, le SMS conserve un état incertain. Ne pas créer une nouvelle clé pour contourner cet état. Un échec après transmission peut conserver une consommation : `failed` ne signifie pas systématiquement « aucun segment envoyé ».

Préférer les webhooks. Si une lecture périodique est nécessaire : une requête à la fois, attente progressive, arrêt sur `delivered`, `failed`, `expired`, `cancelled`. Ne jamais transformer un état incertain ou une réception tardive en nouvel envoi. Des notifications peuvent être dupliquées ou arriver dans le désordre : relire la ressource quand nécessaire et empêcher une ancienne notification de faire reculer l’état local.

### Tolérance aux échecs et résultats incertains

La politique `distinct_operations_v1` suspend le contexte après **trois opérations distinctes consécutives en échec**. Une transmission confirmée remet le compteur consécutif à zéro. Les lectures Pending, doublons, refus avant acceptation et rejeux de la même clé ne sont pas de nouvelles tentatives. Le contexte est le compte + la campagne pour les campagnes API/interface, et le compte + la classe de trafic pour les messages individuels. Changer de clé API ne remet pas le compteur à zéro.

Un échec signé peut rester incertain. Après le premier et le deuxième échec admissible, le destinataire **suivant, distinct**, peut être traité seulement si la fin du traitement physique est vérifiée. Le SMS original reste `reconciling`, avec sa réservation conservée, sans renvoi ni débit confirmé. Seul le contrat précis de résultat d’envoi Android vérifié pour un SMS d’un segment autorise cette continuation ; le seul statut Cloud Failed ne suffit pas. Un envoi actif, un défaut appareil/service, une preuve périmée ou contradictoire et un résultat multipart incertain restent bloquants immédiatement.

Le budget séparé est de **trois résultats/unités non résolus par contexte** et de trois SMS incertains admissibles par appareil partagé. Une réussite ne restitue pas ce budget. La saturation de l’appareil est une indisponibilité de capacité partagée, pas un échec imputé à un autre client. Les délais et trois inspections Cloud ne suffisent pas à inventer un résultat physique.

**Crédit client après échec terminé admissible.** Pour un SMS d’un seul segment dont le résultat précis d’envoi Android est vérifié, signé ou confirmé par la lecture Cloud authentifiée de son identifiant exact, SMSMarocPro prend en charge le risque résiduel : le crédit mensuel et journalier réservé est restitué une seule fois. La ressource est `failed`, avec `transmission: unknown`, `accounting: released`, `accountingPolicy: completed_singlepart_provider_risk_v1` et `creditRestoredAt`. Cette décision commerciale ne prétend pas prouver un non-envoi. L’unité reste comptabilisée dans la capacité incertaine du fournisseur sur sa période SIM d’origine ; elle ne consomme plus le budget d’incertitude du client. Le dossier est clôturé avec la provenance de cette décision. Une éventuelle preuve tardive ne redébite pas le client. Une contradiction reste un arrêt de sécurité avec vérification, et aucun SMS original n’est renvoyé.

La règle ne couvre pas le simple mot Failed, un POST ambigu, un envoi encore actif, un résultat multipart, une identité non vérifiée, une preuve périmée ou contradictoire, ni un défaut appareil/service. Ces cas gardent leur protection et, si nécessaire, leur réservation. Un échec après transmission déjà confirmée reste consommé. Trois échecs consécutifs distincts arrêtent toujours le contexte, même si leurs crédits ont été restitués. La clôture légitime d’anciens dossiers retire seulement le blocage de capacité correspondant ; elle ne remet pas à zéro le compteur d’échecs et ne relance pas une campagne arrêtée.

`GET /v1/messages/{id}` expose `reliability` : `observedFailure` (catégorie sûre, source signée et heure), `transmission` (`unknown`, `confirmed`, `not_sent`, `not_attempted`), `accounting` (`reserved`, `consumed`, `released`, `none`), `processingComplete`, `consecutiveFailures`, `threshold`, `unresolvedCount`, `unresolvedUnits`, `unresolvedUnitLimit`, `stopReason`, `unresolvedSince` (millisecondes Unix), `inspectionCount`, `nextInspectionAt` et `nextAction`. Quand `nextAction` vaut `reconcile_original_outcome`, conserver l’ID original et demander sa résolution au support ; ne pas recréer le message. Les notifications `message.reconciling`/`message.failed` peuvent ajouter `data.observedFailure` avec catégorie, transmission et comptabilité au moment de l’événement ; relire GET pour l’état courant.

Les campagnes conservent leur `state` de cycle de vie, même avec des résultats incertains. `executionState: finished` indique que le curseur a traité tous les destinataires ; `settlementState: pending` indique qu’un rapprochement reste nécessaire. `totals.attempted`, `unattempted` et `confirmedTransmissions` distinguent soumission commencée, destinataire non tenté et transmission confirmée ; `null` signale un ancien historique ou un agrégat en reconstruction non vérifiable. Une campagne en pause reste en pause après un résultat tardif.

La reprise est une décision explicite, jamais une boucle automatique : campagne via `/v1/campaigns/{id}/resume` avec sa révision ; messages individuels via `POST /v1/dispatch/resume`, scope `messages:write`, corps `{"trafficClass":"transactional"}`, et une nouvelle `Idempotency-Key` pour cette décision. Elle remet à zéro un compteur arrêté, conserve les résultats/réservations/délais et ne renvoie aucun SMS. HTTP 409 si une opération est active ou si trois unités restent incertaines. Le rejeu exact de la décision ne réinitialise pas un arrêt ultérieur. Le module téléchargeable fournit `resumeDispatch` pour cette action explicite.

### Arrêter une opération déjà acceptée

Un arrêt de votre workflow ne retire pas les SMS acceptés de la file. Pour chaque message individuel créé par `POST /v1/messages`, utilisez `POST /v1/messages/{id}/cancel`, le droit `messages:write`, une clé d’idempotence d’annulation persistée (distincte de la clé d’envoi) et le corps JSON `{}`. HTTP 200 avec `state: cancelled` confirme l’annulation avant toute tentative appareil. Le helper téléchargeable exporte `cancelMessage({ apiKey, operationKey, messageId })` et ne réessaie pas automatiquement.

HTTP 409 `MESSAGE_NOT_CANCELLABLE` conserve l’état existant : un message déjà pris en charge, transmis ou incertain ne peut pas être rappelé. Les campagnes, messages système et SMS du formulaire web utilisent leurs propres processus ; cette route concerne les messages individuels API. Une réponse perdue reste incertaine : relire l’identifiant original ou rejouer la même clé d’annulation. Ne jamais créer un remplacement. La clé et le compte doivent rester actifs ; le forfait peut avoir expiré. Aucune consommation, réservation d’un autre SMS, opposition ou quarantaine n’est modifiée.

La notification terminale existante `message.failed` peut aussi annoncer une annulation ou une expiration sans tentative. Lire la ressource permet de distinguer `cancelled`, `expired` et `failed`, sans déduire un échec opérateur ou un débit. Le reçu `cancellation.cancelledAt` indique la confirmation de cette annulation, pas une transmission. Publier et vérifier ce contrat sur votre origine avant de l’appeler en production ; une version antérieure peut répondre 404/405.

## 4. Récepteur webhook HTTPS

Créer une URL publique HTTPS acceptant POST sans redirection. L’enregistrer dans l’espace développeur avec les événements nécessaires. Copier le secret de signature une fois. Le premier événement `endpoint.test` doit recevoir 2xx pour activer la destination.

Enregistrer le secret dans la configuration serveur, puis déployer ou redémarrer le récepteur pour charger cette configuration. Le test peut arriver avant cette étape : en cas d’échec, il est réessayé automatiquement. Après une réponse 2xx au test signé, la destination passe à `active` et son statut se met à jour dans l’espace développeur, sans clic supplémentaire. « Actualiser les accès » relit uniquement les accès ; il ne lance ni vérification ni SMS. Si les huit tentatives sont épuisées, corriger le récepteur puis ajouter une destination de remplacement. Ne pas renvoyer un SMS pour tester la configuration webhook.

Exemple de corps :

```json
{"id":"evt_EXEMPLE","type":"message.delivered","occurredAt":"2026-10-08T12:00:00.000Z","data":{"id":"msg_EXEMPLE"}}
```

Les notifications ne contiennent ni texte SMS ni numéro. Les en-têtes sont `X-SMSMarocPro-Event-Id`, `X-SMSMarocPro-Timestamp` (secondes Unix), `X-SMSMarocPro-Signature` (`v1=` suivi de 64 caractères hexadécimaux).

Traiter les identifiants d’événement comme des chaînes opaques : ne pas imposer le préfixe `evt_` montré dans l’exemple. Certaines notifications de passerelle utilisent un identifiant composé. Vérifier l’égalité exacte entre l’identifiant du corps signé et celui de l’en-tête, puis dédupliquer cette valeur complète.

Calcul : **HMAC-SHA256(secret, octets bruts du corps + timestamp), sans séparateur**. Vérifier avec une comparaison constante, une horloge serveur synchronisée et une tolérance de cinq minutes. Vérifier aussi que l’identifiant signé du corps correspond à l’en-tête.

Exemple de route serveur utilisant `verifyWebhook` du module téléchargeable :

```js
import { verifyWebhook } from './smsmarocpro.mjs';

export async function POST(request) {
  // Limiter à 64 Kio au serveur HTTP / reverse proxy avant de lire le corps.
  const bytes = new Uint8Array(await request.arrayBuffer());
  const event = verifyWebhook(bytes, request.headers, process.env.SMSMAROCPRO_WEBHOOK_SECRET);
  if (!event) return new Response(null, { status: 401 });
  // À implémenter avec VOTRE base : dans une seule transaction,
  // insérer event.id (index unique) + l’événement dans une file durable.
  // Si cet ID existe déjà, ne pas recréer le traitement.
  await acceptEventOnceAndEnqueue(event);
  return new Response(null, { status: 204 });
}
```

`acceptEventOnceAndEnqueue` est votre transaction applicative, pas une fonction de notre API. Ne pas utiliser un `Set` en mémoire en production. Le worker de votre file peut traiter `endpoint.test` comme une vérification sans opération métier. Répondre aussi 2xx aux doublons déjà enregistrés. En cas d’échec de la transaction, répondre 5xx afin que l’événement soit réessayé.

Répondre rapidement : délai maximum de dix secondes. Jusqu’à huit tentatives, espacées progressivement à partir de trente secondes ; la destination est désactivée après des échecs répétés. Chaque tentative garde l’ID d’événement, mais actualise timestamp et signature.

Après désactivation, corriger le récepteur, ajouter une destination de remplacement, configurer son nouveau secret puis attendre sa vérification. Pour une rotation sans interruption, accepter temporairement les deux secrets côté serveur, valider la nouvelle destination puis désactiver l’ancienne (dans la limite de cinq destinations). Dédupliquer par ID d’événement même pendant ce chevauchement. Une tentative déjà en cours peut encore arriver après désactivation. Les événements manqués pendant une désactivation et les livraisons épuisées ne sont pas rejoués : réconcilier les opérations connues avec `GET /v1/messages/{id}`. Ne jamais renvoyer le SMS pour récupérer une notification.

Pour une notification créée après cette version, un message individuel API peut inclure `data.operation` : `type: messages.create`, `keyHash` (SHA-256 UTF-8 de la clé d’envoi), `requestHash` et `requestHashAlgorithm: sha256-json-stringify-v1`. Après vérification HMAC, ces deux empreintes permettent de retrouver **exactement** l’opération locale même si la notification précède la réponse d’acceptation. Vérifier aussi l’identifiant accepté par la lecture ci-dessus ; ne jamais rattacher par numéro ou heure seulement. Les empreintes sont un instantané immuable, conservé lors des duplications/reprises webhook. Les anciennes notifications et les campagnes peuvent ne pas les contenir. Le corps, le destinataire et la clé d’opération brute ne sont pas publiés.

## 5. Erreurs, quotas et campagnes

- 400 / 413 / 415 / 422 : corriger la requête, ne pas réessayer à l’identique.
- 401 : clé absente, invalide, expirée ou droits insuffisants.
- 403 : vérifier le compte et le forfait.
- 404 : vérifier identifiant et compte propriétaire.
- 409 : lire le `code` (`IDEMPOTENCY_CONFLICT`, révision ancienne, exclusion, etc.).
- 429 : attendre `Retry-After`, puis conserver la clé d’idempotence.
- 503 ou timeout : attendre progressivement et réutiliser exactement la même opération.

Conserver `X-Request-Id` / `requestId` pour le support, sans secrets, numéros ni corps de SMS. Limite HTTP : 120 requêtes/minute/clé ; elle ne représente pas le débit de SMS.

`GET /v1/usage` conserve ses compteurs mensuels et `dailyMaximum`. L’objet `daily` contient `date` (YYYY-MM-DD), `timeZone` (`Africa/Casablanca`), `windowStart` et `windowEnd` (ISO 8601), `limit`, `consumed`, `reserved` et `remaining` pour la journée du Maroc. `daily: null` signifie indisponible, jamais zéro. Les unités sont des segments SMS ; `remaining = max(0, limit - consumed - reserved)`. Ce relevé n’est pas une réservation : une requête concurrente peut changer le solde avant votre prochain envoi. Une réservation finalisée après minuit reste rattachée à sa journée d’origine. `POST /v1/suppressions` enregistre les oppositions.

SMSMarocPro transporte les messages. Votre application reste responsable de ses codes de vérification, de leur génération, durée de validité, validation et règles métier. Un délai d’expiration du SMS ne remplace pas l’expiration du code dans votre application. Les campagnes et messages individuels partagent le quota ; les messages individuels sont favorisés dans une répartition équitable, sans garantie de délai ni préemption d’un envoi déjà commencé.

Campagne directe : `POST /v1/campaigns` avec `name`, `text`, `to` (20 destinataires maximum) met les messages en file. Pour une liste plus grande : créer un import, envoyer les lots, valider, préparer avec `contactListId` et `expectedListVersion`, attendre `prepared`, puis `POST /v1/campaigns/{id}/launch`. Chaque mutation a sa propre clé d’idempotence ; les contrôles utilisent `expectedRevision`. `resume` reprend la même campagne aux destinataires encore en attente. Créer une nouvelle campagne renvoie à toute la liste.

Pagination campagnes : `limit` de 1 à 100 (50 par défaut), puis `cursor=nextCursor` jusqu’à `nextCursor=null`. Ne pas construire votre propre offset. Pour une opération progressive, attendre `operationPending=false` avant la suivante.

## 6. Validation de votre intégration

Tester d’abord localement avec un transport simulé : acceptation 202, rejeu 200, timeout après acceptation, 409, 429, signature invalide, timestamp expiré, webhook dupliqué, notifications hors ordre et indisponibilité de votre base. Ensuite seulement, envoyer un SMS réel à un numéro autorisé et vérifier réception, consommation et webhook sans renvoi manuel. Aucun test physique n’est déclenché par la lecture de ce guide.


## 8. Compatibilité du texte final — `ma_sms_content_v1`

La règle concerne le **texte dans le SMS**, pas les appels API ou les webhooks, qui restent en HTTPS. Pour les nouvelles acceptations et préparations, les schémas `http://` / `https://` (sans distinction de casse) sont refusés. Utiliser volontairement `exemple.ma/page` si cela convient à votre message. Ce choix de compatibilité ne garantit pas la réception et n'est pas présenté comme une règle universelle des opérateurs.

Une suite **maximale ininterrompue de exactement cinq ou six chiffres décimaux Unicode** est refusée, y compris les chiffres arabes. Un numéro plus long ne contient pas, au sens de cette règle, une telle suite. Le client peut volontairement grouper une référence : `12-345`, `123-45`, `123-456`. Le transport ne réécrit jamais le texte.

Les erreurs HTTP 422 `MESSAGE_URL_SCHEME_NOT_ALLOWED` et `MESSAGE_DIGIT_RUN_NOT_ALLOWED` sont permanentes pour le corps inchangé. Si les deux règles échouent, le schéma URL a priorité. La réponse n'inclut ni le code ni le texte fautif. Corriger explicitement avant une nouvelle opération. Les variables et suffixes sont vérifiés sur chaque message final pendant la préparation d'une campagne ; une préparation interrompue ne peut pas être lancée.

Pour un OTP : votre application conserve la génération, l'entropie, l'expiration, la vérification et les limites d'essai. Afficher volontairement le code sous forme `123-456` et adapter votre saisie/collage sans changer la valeur sous-jacente. Tester l'autoremplissage sur vos appareils ; il n'est pas garanti avec des groupes. Le tiret ajoute un caractère : recalculer les segments (160 caractères simples deviennent deux segments à 161).

Les opérations/snapshots déjà acceptés gardent leur texte. Un rejeu exact conserve son identifiant même si une règle a changé ; un corps modifié sous la même clé reste un conflit. Aucun historique n'est réécrit ou rejoué automatiquement. `/v1/messages/analyze` reste une analyse d'encodage sandbox ; l'aperçu « Avant l'envoi » vérifie aussi cette compatibilité, sans vérifier disponibilité ni droits réels.

## 9. Disponibilité et pression d'envoi

`GET /v1/availability?trafficClass=transactional&requiredUnits=1` nécessite `usage:read`. `trafficClass` accepte `transactional` ou `bulk`, `requiredUnits` 1 ou 2. La réponse non mise en cache contient `status` (`ready` / `unavailable`), `reason`, `operatorActionRequired`, `retryAfterSeconds` (secondes ou null), `checkedAt` et `advisory: true`.

Raisons possibles : `ready`, `busy`, `offline`, `reconciliation`, `capacity`, `processing_paused`, `service_configuration`, `monthly_quota`, `daily_quota`, `account_not_eligible`, `account_state_unavailable`. Cette lecture n'est **ni une réservation ni une garantie de délai** : disponibilité et quota sont vérifiés à nouveau au traitement. Elle ne valide pas un destinataire ou le contenu.

Pour un message immédiat uniquement, ajouter `requireReady: true` au corps de `POST /v1/messages`. Si le service n'est pas prêt, HTTP 503 avec `code: DISPATCH_UNAVAILABLE`, `availabilityReason`, `operatorActionRequired` et `retryAfterSeconds` ; aucune opération n'est acceptée. Respecter `Retry-After` s'il existe. Un rejeu exact déjà accepté reste récupérable. Sans cette option, l'acceptation asynchrone existante est conservée, y compris si le service est momentanément indisponible. Utiliser `sendBy` pour limiter la validité utile du message.

Ne pas interroger cette lecture avant chaque destinataire : l'utiliser au démarrage et lors d'un blocage, avec attente bornée. En cas d'intervention requise, suspendre les **nouveaux** envois et conserver les identifiants acceptés. Limiter le nombre d'opérations en vol dans votre base ; attendre leurs vrais résultats avant de soumettre les suivantes. Trois réponses Pending ne sont pas trois échecs.

Une opération active dont la transmission est inconnue indique `reason: reconciliation`, et non une simple attente `busy`. L'objet facultatif `recovery` précise l'identifiant du message de votre compte, `status` (`active`, `exhausted`, `review_required`), `automaticRecoveryActive`, `checks`, `maximumChecks`, `nextCheckAt`, `stopAt`, `deadlineExpired` et `nextAction`. `since` est l'origine en millisecondes Unix ; les champs finissant par `At` sont des dates ISO. La réponse 503 de `requireReady` conserve ce même diagnostic. Une attente active suit le prochain contrôle ; une revue requise n'annonce aucun délai de reprise.

La politique `original_id_bounded_v1` réserve au plus dix lectures du même identifiant Cloud, avec délais de 30, 60, 120, 240, 480 puis 900 secondes, dans une fenêtre maximale de deux heures depuis la soumission originale. Une lecture réservée compte même si le worker s'arrête avant de la finir. Ces délais ne prolongent ni `sendBy`, ni un workflow client. Les trois contrôles d'un échec signé et le suivi d'un accusé de livraison restent des politiques distinctes. L'épuisement arrête les lectures automatiques ; il ne transforme pas l'incertitude en succès, non-envoi ou remboursement. Une preuve tardive peut encore régler l'opération originale une seule fois. Un autre contexte peut utiliser un appareil sain ; l'opération incertaine n'est jamais transférée vers un autre appareil.

`GET /v1/messages/{id}` ajoute `dispatch.submissionAttempts`, `dispatch.confirmedTransmissions` et `dispatch.outcome`. Une préparation interne ne compte pas comme tentative de soumission. `expired_without_submission` signifie une expiration sans soumission ; `uncertain` ne prouve ni transmission ni absence de transmission ; `transmission_confirmed` reste vrai même si la réception échoue ensuite. `quotaUnits` est une estimation de segments, pas un débit confirmé. Une réception multipart complète n'est pas déduite de plusieurs notifications sans identité de partie.

`GET /v1/usage` fait foi pour `consumed`, `reserved`, `remaining` et `daily`. Les réservations ne sont pas des consommations et les contraintes mensuelle/quotidienne portent sur les mêmes unités. Un état `failed` n'implique ni remboursement ni absence de transmission. Compter les échecs terminaux par opération persistée, dédupliquer les callbacks et traiter les résultats dans l'ordre métier ; une issue précédente inconnue reste inconnue. Ne jamais créer un SMS de remplacement pour contourner une attente ou une ambiguïté.

## 10. Échecs et récupération des notifications

La ressource du message ajoute `failure` (objet ou null). Les nouvelles notifications d'échec ou de rapprochement ajoutent le même objet figé dans `data.failure` : `code`, `category`, `detail`, `source`, `transmission` et `accounting`. `detail` est une explication sûre ; aucun texte brut de passerelle n'est transmis. L'ancien champ facultatif `observedFailure` reste compatible. Des événements historiques peuvent omettre le nouvel objet : lire le message original, sans inventer de cause.

Si `accounting: unknown` apparaît sur un historique incomplet, la comptabilité doit être vérifiée ; ce champ ne signifie pas une consommation nulle. Une transmission prouvée après un crédit déjà restitué conserve ce crédit selon la politique de risque appliquée.

| Code | Sens |
| --- | --- |
| `GATEWAY_REQUEST_REJECTED` / `GATEWAY_AUTHENTICATION_REJECTED` | Refus connu avant mise en file de la passerelle ; aucun diagnostic d'invalidité du destinataire n'est déduit. |
| `GENERIC_SEND_FAILURE` / `SEND_FAILURE_REPORTED` | Échec signalé ; consulter les champs transmission et comptabilité avant toute décision. |
| `POST_TRANSMISSION_FAILURE` | Échec ultérieur à une transmission confirmée ; le SMS reste consommé. |
| `TRANSMISSION_UNKNOWN` | Résultat original à rapprocher ; aucune soumission de remplacement. |
| `OUTCOME_REVIEW_REQUIRED` | Transmission enregistrée et preuves contradictoires à examiner ; aucune déduction de livraison. |
| `MESSAGE_EXPIRED` / `MESSAGE_CANCELLED` | Expiration ou annulation ; consulter la preuve de transmission. |
| `MESSAGE_FAILURE_UNCLASSIFIED` | Les preuves conservées ne permettent pas de préciser la cause. |

Une catégorie `generic_failure` ne prouve pas qu'un numéro est invalide. Un refus d'admission HTTP n'est pas un SMS tenté. Depuis le contrat 1.9.0, les totaux de campagne séparent `failed`, `cancelled` et `expired`. Additionner ces trois champs seulement si votre affichage souhaite un total d'issues non abouties. `attempted`, `confirmedTransmissions` et `unattempted` restent distincts ; un rollup incomplet conserve un règlement en attente.

`GET /v1/webhook-events?limit=50&cursor=…` nécessite `messages:read`. La réponse contient `data` et `nextCursor`. Chaque ligne décrit un événement pour un endpoint public : identifiant opaque, type, date originale, données figées, état et tentatives de livraison, dernier statut HTTP, nombre de rejeux et `expiresAt`. Le même événement peut apparaître pour plusieurs endpoints. La fenêtre de consultation et de rejeu est de 30 jours depuis sa création originale ; elle ne redémarre pas lors d'un rejeu. Il s'agit d'une fenêtre d'accès API, pas d'une promesse d'effacement immédiat des métadonnées d'audit.

Un HTTP 2xx de votre endpoint signifie **réception durable de la notification**. Il ne certifie pas la mise à jour de vos feuilles, de vos compteurs ou d'un service aval. Persister la notification dans votre propre file avant d'accuser réception, puis dédupliquer par identifiant d'événement. Une réponse perdue peut produire une nouvelle livraison du même événement. Un HTTP 200 contenant un résultat métier négatif reste un accusé de réception pour cette interface ; vos traitements aval doivent être réparés depuis votre file ou par un rejeu explicite.

Pour réparer un événement conservé, utiliser `POST /v1/webhook-events/{eventId}/replay`, `messages:write`, un `Idempotency-Key` persistant et un corps JSON `{ "endpointId": "wh_…" }`. Encoder l'identifiant opaque dans le chemin. Le corps peut ajouter `targetEndpointId` pour un endpoint de remplacement, déjà vérifié, actif et abonné au même type dans votre compte. Aucune URL arbitraire n'est acceptée.

Le résultat 202 confirme la mise en file de la notification ; un rejeu exact de la demande retourne 200. L'événement conserve son identifiant, sa date et ses données originales, avec un nouvel horodatage de signature. Vérifier à nouveau HMAC et dédupliquer. Au plus trois cycles de rejeu par événement/endpoint, espacés d'au moins 60 secondes ; chaque cycle garde au plus huit tentatives avec backoff. Un cycle prêt/en cours, un endpoint inactif ou une limite atteinte retourne 409. Une clé réutilisée avec un autre corps retourne également 409. Une rotation de clé API dans le même compte conserve l'idempotence. Un historique expiré connu retourne 410 `WEBHOOK_HISTORY_UNAVAILABLE` ; un événement absent ou étranger retourne 404. Aucun de ces résultats n'autorise un nouveau SMS.

Le rejeu concerne uniquement la notification. Il ne soumet aucun SMS, ne modifie aucun crédit, ne réinitialise aucun circuit breaker et ne transforme pas une ancienne notification en état courant. Toujours relire le message original si un événement arrive en retard ou hors ordre.
