SMS

Envoyer à une liste

Textez toute une liste depuis un seul numéro : le temps que ça prend, diffusion à un groupe ou boucle d’envois, refus et nouvelles tentatives, et quand passer au sans frais.

Ce guide vise les messages que les personnes de votre liste attendent de vous, comme des rappels de match pour une équipe ou des rappels de rendez-vous pour des patients. Chaque destinataire doit avoir un consentement LCAP enregistré, et le marketing de masse est interdit sur HonkIO (voir les Conditions d’utilisation).

Pourquoi une liste prend du temps

Un numéro local (à 10 chiffres) envoie 10 parties de message par minute. C’est le rythme auquel l’opérateur les envoie. Chaque message envoyé depuis ce numéro attend son tour dans la file du numéro : une longue liste part à ce rythme, peu importe la vitesse de votre code.

Un message de 160 caractères GSM-7 ou moins tient en une partie. Au-delà, il est découpé en parties de 153 caractères, et chaque partie compte. Avec un émoji ou un autre caractère Unicode, les limites passent à 70 caractères pour une partie et à 67 par partie d’un message plus long. Le champ segment_count de chaque message indique combien de parties il a pris.

Exemple : une ligue envoie un rappel de 5 parties à 36 joueurs depuis un numéro local. Cela fait 36 × 5 = 180 parties, soit environ 18 minutes du premier au dernier. Le même rappel ramené à une partie fait 36 parties, moins de 4 minutes.

Diffusion à un groupe ou boucle d’envois

Il y a deux façons d’envoyer à une liste, et elles réagissent différemment quand le numéro est occupé.

  • Une boucle d’envois simples (POST /v1/messages, une fois par personne). Un envoi est refusé avec 429 NUMBER_RATE_LIMITED tant qu’une minute de parties attend déjà pour ce numéro. L’en-tête Retry-After et details.retry_after_seconds indiquent quand il devrait y avoir de la place, et rien n’est facturé pour l’envoi refusé. Votre boucle attend, renvoie, et avance donc au rythme du numéro.
  • Une diffusion à un groupe de contacts (POST /v1/contact-groups/{id}/messages). Une seule requête envoie à tous les membres du groupe. Elle n’est pas soumise au refus par minute : elle prend d’emblée toutes ses parties dans la file de l’opérateur pour le numéro. Si elle devait porter la file d’un numéro local à plus de 1 200 parties, la diffusion entière est refusée, avant tout envoi, avec 429 NUMBER_QUEUE_FULL. Retry-After est fourni quand attendre libérerait de la place. Quand la diffusion ne pourra jamais tenir, par exemple 250 membres × 5 parties, divisez le groupe ou raccourcissez le message.
bash
# One request for the whole group: every member's parts are queued up front
curl -X POST https://api.honkio.ca/v1/contact-groups/GROUP_ID/messages \
  -H "Authorization: Bearer mk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: game-2026-10-17" \
  -d '{
    "from": "+1416XXXXXXX",
    "body": "Rideau Volleyball: games tonight start at 7 pm on court 3. Reply STOP to opt out."
  }'

# Response 207: check each entry in results, not the HTTP status
# { "group_id": "...", "sent_to": 36, "failed": 0,
#   "results": [{ "to": "+1613XXXXXXX", "status": "...", "message_id": "..." }, ...],
#   "estimated_completion_at": "2026-10-17T22:08:00.000Z" }

# Response 429 when the parts would not fit in the number's queue (1,200 parts on a local number):
# { "code": "NUMBER_QUEUE_FULL", "message": "...",
#   "details": { "queued_parts": 1100, "requested_parts": 180, "max_parts": 1200, "retry_after_seconds": 480 } }
# retry_after_seconds is null, with no Retry-After, when it can never fit: split the group or shorten the message.

Une diffusion répond 207 avec un résultat par membre : vérifiez chaque entrée plutôt que le code HTTP. Elle compte aussi dans les limites de diffusion (voir Limites d’envoi).

Si la liste est déjà un groupe de contacts et que tout le monde reçoit le même texte, la diffusion est le choix le plus simple : une requête, acceptée ou refusée en bloc. Une boucle convient aux messages qui changent d’une personne à l’autre, comme un nom ou une heure dans le texte.

Un message, une partie

Les parties fixent à la fois le rythme et ce que vous payez, puisque chaque partie est facturée. La liste la plus rapide est donc celle dont le message tient en une partie : 160 caractères de lettres, de chiffres et de ponctuation courante.

Un seul émoji, ou une lettre hors GSM-7 comme ê, ç ou ô, rend tout le message Unicode et ramène la limite d’une partie à 70 caractères. Les lettres é, è et à font partie du GSM-7.

Retirez les formules d’appel et de politesse, mettez un lien vers les détails (avec l’adresse complète : les raccourcisseurs de liens sont refusés) plutôt que de tout écrire, et vérifiez segment_count sur un envoi de test avant le vrai.

Gérer les refus

Un envoi refusé ne coûte rien, et la réponse indique quoi faire ensuite.

  • Attendez le délai Retry-After, puis renvoyez. 429 NUMBER_RATE_LIMITED, 503 CARRIER_THROTTLED et, quand attendre aiderait, 429 NUMBER_QUEUE_FULL portent un en-tête Retry-After et details.retry_after_seconds. 429 RATE_LIMITED (plus de 100 requêtes par seconde) porte aussi Retry-After.
  • 503 CARRIER_THROTTLED signifie que l’opérateur nous a demandé de ralentir et n’a pas pris le message. Rien n’est facturé; réessayez après le délai Retry-After avec la même Idempotency-Key, et cette nouvelle tentative l’envoie. Le même code peut aussi arriver plus tard, comme error_code d’un message FAILED et de son webhook message.failed, quand l’opérateur a pris le message puis l’a abandonné parce que la file était pleine. Ce message est remboursé, et la même clé le renvoie maintenant en échec : renvoyez-le avec une nouvelle Idempotency-Key.
  • Réutilisez l’Idempotency-Key. Donnez à chaque message sa propre clé, par exemple le rappel et la personne visée, et renvoyez la même clé à chaque nouvelle tentative de ce message. Si une tentative précédente est passée, la nouvelle renvoie ce message au lieu de l’envoyer et de le facturer une seconde fois.
  • 503 CARRIER_TIMEOUT est différent : le message est peut-être parti, ou non. Vérifiez son statut avant de l’envoyer de nouveau (voir Erreurs).
  • Ne réessayez pas ceux-ci en boucle : DAILY_LIMIT_REACHED, FANOUT_LIMIT_REACHED, RECIPIENT_RATE_LIMITED et SENDING_PAUSED. Ils signalent une limite quotidienne, une limite par destinataire ou une pause, et renvoyer quelques secondes plus tard n’y changera rien. Arrêtez la tâche, lisez les details de l’erreur et consultez les Limites d’envoi.

Laissez le SDK réessayer

À partir de la version 0.8.0 du SDK Node.js et de la version 0.2.0 des SDK Python et PHP (donc aussi du paquet Laravel), les SDK officiels réessaient d’eux-mêmes NUMBER_RATE_LIMITED, CARRIER_UNAVAILABLE et CARRIER_THROTTLED. Ils ne réessaient RATE_LIMITED que sur un GET ou sur une requête qui porte une Idempotency-Key, ce que fait chaque envoi passé par eux : ils en ajoutent une automatiquement si vous n’en passez pas. Ils attendent le délai Retry-After, réessaient deux fois par défaut et n’attendent pas un délai unique de plus de 60 secondes : un délai plus long revient à votre code sous forme d’erreur. Chaque nouvelle tentative porte la même clé et ne peut donc pas envoyer deux fois.

Une tâche d’envoi à une liste a quand même besoin de sa propre boucle pour les attentes plus longues. L’exemple envoie un rappel par membre, chacun avec sa propre clé, attend quand le SDK lui renvoie un refus, et s’arrête sur les codes ci-dessus. Passez votre propre clé, comme ici, pour que vos propres tentatives soient couvertes aussi.

// One send per member, each with its own idempotency key, so a retry can never send or charge twice.
// The SDK already retries these codes when the wait is short; this loop waits out the longer ones.
const RETRY = new Set(['NUMBER_RATE_LIMITED', 'RATE_LIMITED', 'CARRIER_UNAVAILABLE', 'CARRIER_THROTTLED'])
const sleep = (seconds: number) => new Promise((resolve) => setTimeout(resolve, seconds * 1000))

const members = [{ id: 'player-17', phone: '+1613XXXXXXX' }]
const body = 'Rideau Volleyball: games tonight start at 7 pm on court 3. Reply STOP to opt out.'

for (const member of members) {
  for (let attempt = 1; ; attempt++) {
    const { data, error } = await honkio.messages.send(
      { from: '+1416XXXXXXX', to: member.phone, body },
      { idempotencyKey: `game-2026-10-17-${member.id}` },
    )
    if (!error) {
      console.log(member.phone, data.mode, data.status, data.segment_count) // mode TEST: simulated, not sent
      break
    }
    // DAILY_LIMIT_REACHED, FANOUT_LIMIT_REACHED, RECIPIENT_RATE_LIMITED, SENDING_PAUSED and the rest: stop and look
    if (!RETRY.has(error.name) || attempt === 5) throw new Error(`${error.name}: ${error.message}`)
    const details = error.details as { retry_after_seconds?: number } | undefined
    await sleep(details?.retry_after_seconds ?? 1)
  }
}

Savoir quand les messages partent

La réponse d’un envoi en production inclut estimated_send_at : notre estimation du moment où l’opérateur enverra ce message, selon ce qui attend déjà dans la file du numéro. C’est une heure ISO 8601, ou null.

La réponse 207 d’une diffusion inclut estimated_completion_at, notre estimation du moment où l’opérateur aura envoyé au dernier membre.

GET /v1/send-limit inclut number_queues, dont chaque entrée donne le phone_number d’un numéro, ses parts_per_minute, les queued_parts en attente et clears_at, le moment où sa file devrait être vide. Consultez-le avant un gros envoi pour voir si un numéro est déjà occupé. À partir de la version 0.8.0 du SDK Node.js et de la version 0.2.0 des SDK Python et PHP, les SDK le lisent aussi : sendLimit.get() en Node.js, send_limit.get() en Python et $honkio->sendLimit->get() en PHP.

bash
curl https://api.honkio.ca/v1/send-limit \
  -H "Authorization: Bearer mk_live_YOUR_KEY"

# Response 200 (other fields as on Pricing and limits):
# { ...,
#   "number_queues": [
#     { "phone_number": "+1416XXXXXXX", "parts_per_minute": 10,
#       "queued_parts": 120, "clears_at": "2026-10-17T22:16:00.000Z" }
#   ] }

Ce sont des estimations, pas des promesses. Elles portent sur le moment où l’opérateur envoie, pas sur celui où un téléphone reçoit : la livraison se lit dans le statut du message et ses webhooks.

Les clés de test n’envoient rien

Une clé de test (mk_test_…) simule les envois : aucun message n’atteint un téléphone, même si l’API indique le statut DELIVERED. Le champ mode de chaque message, LIVE ou TEST, les distingue, et le tableau de bord marque les envois de test « Test · non envoyé ». Pour envoyer pour vrai, il faut une clé de production (mk_live_…); voir Authentification et clés API.

Les envois de test ne sont pas non plus soumis aux limites d’envoi : une liste qui se termine en quelques secondes en mode test prend tout son temps en production.

Envoyer de vrais volumes

Un numéro sans frais vérifié envoie jusqu’à 1 200 parties par minute; c’est la voie pour les listes qui doivent partir vite. Il demande une vérification unique de votre entreprise; voir Numéros sans frais.

Ne répartissez pas une liste sur plusieurs numéros locaux pour contourner le rythme. Les opérateurs appellent cette pratique le « snowshoeing ». Elle enfreint la politique d’utilisation acceptable de l’opérateur et la nôtre (voir les Conditions d’utilisation).