SMS

Sending to a list

Text everyone on a list from one number: how long it takes, a group broadcast versus a loop of sends, handling refusals and retries, and when to move to toll-free.

This guide is for messages the people on your list expect from you, such as game reminders for a team or appointment reminders for patients. Every recipient needs CASL consent on file, and bulk marketing is not allowed on HonkIO (see the Terms of Service).

Why a list takes time

A local (10-digit) number sends 10 message parts a minute. That is the pace the carrier sends them at. Every message you send from the number waits its turn in that number’s queue, so a long list goes out at that pace however fast your code sends.

A message of up to 160 GSM-7 characters is one part. A longer one is split into parts of 153 characters, and each part counts. With an emoji or any other Unicode character, the limits are 70 characters for one part and 67 for each part of a longer message. Each message’s segment_count says how many parts it took.

Example: a league texts a 5-part reminder to 36 players from one local number. That is 36 × 5 = 180 parts, about 18 minutes from the first to the last. The same reminder cut to one part is 36 parts, under 4 minutes.

A group broadcast or a loop of sends

There are two ways to send to a list, and they handle a busy number differently.

  • A loop of single sends (POST /v1/messages, once per person). A send is refused with 429 NUMBER_RATE_LIMITED while a minute’s worth of parts is already waiting for that number. The Retry-After header and details.retry_after_seconds say when there should be room, and nothing is charged for the refused send. Your loop waits, sends again, and so moves at the number’s pace.
  • A contact-group broadcast (POST /v1/contact-groups/{id}/messages). One request sends to every member of the group. It skips the per-minute refusal: it takes all its parts in the number’s carrier queue up front. If that would put more than 1,200 parts in a local number’s queue, the whole broadcast is refused, before anyone is messaged, with 429 NUMBER_QUEUE_FULL. Retry-After is set when waiting would make room. When the broadcast can never fit, for example 250 members × 5 parts, split the group or shorten the 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.

A broadcast answers 207 with one result per member, so check each entry rather than the HTTP status. It also counts against the broadcast limits (see Sending limits).

If the list is already a contact group and everyone gets the same text, a broadcast is the simpler choice: one request, accepted or refused as a whole. A loop suits messages that differ from person to person, such as a name or a time in the body.

Keep each message to one part

Parts set both the pace and what you pay, since each part is billed. The quickest list is one whose message fits in one part: 160 characters of plain letters, digits and common punctuation.

One emoji, or a letter outside GSM-7 such as ê, ç or ô, makes the whole message Unicode and drops the one-part limit to 70 characters. The letters é, è and à are in GSM-7.

Drop greetings and sign-offs, link to the details (with the full URL: link shorteners are refused) instead of spelling them out, and check segment_count on a test send before the real one.

Handling refusals

A refused send costs nothing, and the response says what to do next.

  • Wait for Retry-After, then send again. 429 NUMBER_RATE_LIMITED, 503 CARRIER_THROTTLED and, when waiting would help, 429 NUMBER_QUEUE_FULL carry a Retry-After header and details.retry_after_seconds. 429 RATE_LIMITED (more than 100 requests a second) carries Retry-After too.
  • 503 CARRIER_THROTTLED means the carrier asked us to slow down and did not take the message. Nothing is charged; retry after the Retry-After delay with the same Idempotency-Key, and that retry sends it. The same code can also arrive later, as the error_code of a FAILED message and its message.failed webhook, when the carrier took the message and then dropped it from a full queue. That message is refunded, and the same key now returns it as failed, so resend it with a new Idempotency-Key.
  • Reuse the Idempotency-Key. Give each message its own key, such as the reminder and the person it is for, and send the same key on every retry of that message. If an earlier attempt did go through, the retry returns that message instead of sending and charging again.
  • 503 CARRIER_TIMEOUT is different: the message may or may not have gone out. Check its status before you send it again (see Errors).
  • Do not retry these in a loop: DAILY_LIMIT_REACHED, FANOUT_LIMIT_REACHED, RECIPIENT_RATE_LIMITED and SENDING_PAUSED. They mean a daily limit, a per-recipient limit or a pause, and sending again a few seconds later will not get past them. Stop the job, read the error’s details, and see Sending limits.

Let the SDK retry

From version 0.8.0 of the Node.js SDK and version 0.2.0 of the Python and PHP SDKs (and so the Laravel package), the official SDKs retry NUMBER_RATE_LIMITED, CARRIER_UNAVAILABLE and CARRIER_THROTTLED on their own. They retry RATE_LIMITED only on a GET or on a request that carries an Idempotency-Key, and every send through them does: they add one automatically when you pass none. They wait for Retry-After, retry twice by default, and do not wait out a single delay longer than 60 seconds: a longer one comes back to your code as the error. Each retry carries the same key, so it cannot send twice.

A list job still needs its own loop for the longer waits. The sample sends one reminder per member, each with its own key, waits out a refusal the SDK hands back, and stops on the codes above. Pass your own key, as here, so your own retries are covered too.

// 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)
  }
}

Knowing when messages go out

A live send’s response includes estimated_send_at: our estimate of when the carrier will send that message, given what is already queued on the number. It is an ISO 8601 time, or null.

A broadcast’s 207 response includes estimated_completion_at, our estimate of when the carrier will have sent to the last member.

GET /v1/send-limit includes number_queues, each entry with a number’s phone_number, its parts_per_minute, the queued_parts waiting and clears_at, when its queue should be empty. Check it before a large send to see whether a number is already busy. From version 0.8.0 of the Node.js SDK and version 0.2.0 of the Python and PHP SDKs, the SDKs read it too: sendLimit.get() in Node.js, send_limit.get() in Python and $honkio->sendLimit->get() in 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" }
#   ] }

These are estimates, not promises. They are about when the carrier sends, not when a phone receives: delivery shows in the message’s status and its webhooks.

Test keys do not send

A test key (mk_test_…) simulates sends: no message reaches a phone, though the API reports the status DELIVERED. Each message’s mode, LIVE or TEST, tells them apart, and the dashboard labels test sends “Test · not sent”. Sending for real needs a live key (mk_live_…); see Authentication and API keys.

Test sends also skip the sending limits, so a list that finishes in seconds in test mode takes its full time live.

Sending real volume

A verified toll-free number sends up to 1,200 parts a minute and is the route for lists that need to go out quickly. It needs a one-time verification of your business; see Toll-free numbers.

Do not spread one list across several local numbers to get around the pace. Carriers call this snowshoeing. It breaks the carrier’s acceptable use policy and ours (see the Terms of Service).