SMS
Sending messages
Send an SMS from a number you own. The API checks the Canadian destination and CASL consent before anything reaches the carrier.
Sending SMS
Send a message using a provisioned number. The API validates the Canadian destination number, and checks CASL consent before delivery.
// "from" is one of your HonkIO numbers; "to" is a real number you hold consent for.
// Reuse the idempotency key on a retry: it returns the original message instead of sending and charging again.
const { data, error } = await honkio.messages.send({
from: '+1416XXXXXXX',
to: '+1613XXXXXXX',
body: 'Hello from HonkIO! 🇨🇦',
}, { idempotencyKey: 'order-1042-shipped' })
if (error) throw new Error(`${error.name}: ${error.message}`)
console.log(data.id, data.status)# "from" is one of your HonkIO numbers; "to" is a real number you hold consent for.
# Reuse the idempotency key on a retry: it returns the original message instead of sending and charging again.
curl -X POST https://api.honkio.ca/v1/messages \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-shipped" \
-d '{
"from": "+1416XXXXXXX",
"to": "+1613XXXXXXX",
"body": "Hello from HonkIO! 🇨🇦"
}'Contacts (POST /v1/contacts) take a phone_number, an email, or both. Groups, broadcasts and allow/deny lists use contacts that have a phone number; adding one without is refused with 422 CONTACT_HAS_NO_PHONE, and clearing the phone of a contact in a group or list with 409 CONTACT_IN_USE. Contacts, groups and lists are kept per key mode: a test key sees and changes test ones only.
Sending the same message to many people? See Sending to a list for how fast a number sends, group broadcasts, and how to handle 429 refusals.
Scheduling a message
Add scheduled_at to send later, such as an appointment reminder the day before: an ISO 8601 date-time with an offset, 1 minute to 30 days ahead. The answer is 201 with status SCHEDULED and scheduled_at, and a live send is charged now (its estimated_send_at is its scheduled_at). Every message you read back carries scheduled_at, null on one sent at once.
const { data, error } = await honkio.messages.send({
from: '+1416XXXXXXX',
to: '+1613XXXXXXX',
body: 'Reminder: your appointment at Acme Dental is tomorrow at 2 pm.',
scheduledAt: '2026-10-01T14:00:00-04:00',
expiresAt: '2026-10-01T20:00:00-04:00', // optional: not after 8 pm (default: 24 hours after scheduledAt)
}, { idempotencyKey: 'appt-5521-reminder' })
if (error) throw new Error(`${error.name}: ${error.message}`)
console.log(data.id, data.status, data.scheduled_at, data.expires_at) // SCHEDULEDcurl -X POST https://api.honkio.ca/v1/messages \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: appt-5521-reminder" \
-d '{
"from": "+1416XXXXXXX",
"to": "+1613XXXXXXX",
"body": "Reminder: your appointment at Acme Dental is tomorrow at 2 pm.",
"scheduled_at": "2026-10-01T14:00:00-04:00",
"expires_at": "2026-10-01T20:00:00-04:00"
}'
# Response 201: { "id": "...", "status": "SCHEDULED", "scheduled_at": "2026-10-01T18:00:00.000Z",
# "expires_at": "2026-10-02T00:00:00.000Z", ... }
# expires_at is optional: without it the message must go out within 24 hours of scheduled_at.A scheduled message must go out within 24 hours of its scheduled_at. For a tighter deadline, such as a reminder that is no use once the appointment has started, add expires_at: an ISO 8601 date-time with an offset, at least 1 minute and at most 24 hours after scheduled_at, and accepted only together with scheduled_at (test keys are held to the same rules; anything else is 422 VALIDATION_ERROR). A message that cannot go out by its deadline fails with error_code SCHEDULED_SEND_EXPIRED: nothing is sent, the charge is refunded and message.failed fires. Every message you read back carries expires_at, null when none was given, which means the 24 hour default.
While it is still SCHEDULED, PATCH /v1/messages/:id with a new scheduled_at moves it (the key needs messages:m) and DELETE /v1/messages/:id cancels it and refunds the charge (messages:d). A cancel cannot be undone. Either one on a message that is no longer scheduled is 422 MESSAGE_NOT_SCHEDULED, with details.current_status. They fire message.rescheduled and message.cancelled.
// Move it, or cancel it (refunded), while it is still scheduled:
const moved = await honkio.messages.update('MESSAGE_ID', { scheduledAt: '2026-10-02T09:00:00-04:00' })
if (moved.error) throw new Error(moved.error.message)
const cancelled = await honkio.messages.cancel('MESSAGE_ID')
if (cancelled.error) throw new Error(cancelled.error.message)# Move it, or cancel it (refunded), while it is still scheduled:
curl -X PATCH https://api.honkio.ca/v1/messages/MESSAGE_ID -H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" -d '{ "scheduled_at": "2026-10-02T09:00:00-04:00" }'
curl -X DELETE https://api.honkio.ca/v1/messages/MESSAGE_ID -H "Authorization: Bearer mk_live_YOUR_KEY"PATCH also takes expires_at, checked against the new scheduled_at, or null for the 24 hour default. If you leave it out, a stored expires_at is kept while it still fits the new time (1 minute to 24 hours after it) and is otherwise dropped, back to the 24 hour default. The answer carries id, status, scheduled_at and expires_at.
The send checks run twice. When you schedule, every check runs except the sending limits (daily limit, per-number pace, per-recipient caps, distinct recipients), and a paused account gets 403 SENDING_PAUSED. When it is due, every check runs again: if the recipient opted out since, consent lapsed, the key was revoked, the number or the account was suspended, or rent is owed, it is not sent. It becomes FAILED with that error_code, is refunded and fires message.failed.
A sending limit or a pause does not fail a message that is due: it stays SCHEDULED and waits until it can go, then goes to the carrier like any other send. If the carrier then throttles it (CARRIER_THROTTLED) or cannot be reached (CARRIER_UNAVAILABLE), the message goes back to SCHEDULED and is tried again: after the wait the carrier asks for (its Retry-After) when it names one, otherwise after about 1, 2, 5 and 10 minutes, then every 10 minutes. It is charged once, however many tries it takes. A message sent at once is not retried this way: it is answered 503 straight away. None of this waiting runs past the deadline: a message still held by a pause, a sending limit or the carrier then fails with SCHEDULED_SEND_EXPIRED and is refunded, even while the account stays paused.
An account may hold 10,000 scheduled live messages and 1,000 scheduled test messages at once, one per message and one per member of a scheduled group broadcast. Past that, scheduling is refused with 429 SCHEDULED_LIMIT_REACHED (details carry mode, limit, scheduled and requested), and nothing is charged; a broadcast that would not fit is refused whole. A message stops counting once it is sent, fails, expires or is cancelled.
With a test key a scheduled message is free. When it is due it is checked against the test key's consents and opt-outs, then delivered by simulation, never to a phone, and a pause never holds it. An Idempotency-Key used for a send now cannot be reused for a scheduled one, or the other way round: 409 IDEMPOTENCY_KEY_REUSED.
GET /v1/messages takes status=SCHEDULED to list what is waiting, and status=CANCELLED for what was cancelled.
HonkIO