---
title: "Send an SMS message"
method: POST
path: "/v1/messages"
tags: ["Messages"]
---

# Send an SMS message

`POST /v1/messages`

## Request

- Base URL: `https://api.honkio.ca`
- URL: `https://api.honkio.ca/v1/messages`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-API-Key`

Live sends are gated before any charge: 403 SENDING_PAUSED, 429 DAILY_LIMIT_REACHED / NUMBER_RATE_LIMITED / RECIPIENT_RATE_LIMITED (NUMBER_RATE_LIMITED: the sending number already has a minute of message parts waiting for the carrier, which sends 10 parts a minute from a local number; RECIPIENT_RATE_LIMITED: 30 messages an hour and 100 a day to one recipient; Retry-After is set on both) / FANOUT_LIMIT_REACHED, 422 UNDELIVERABLE_NUMBER (three consecutive carrier failures to the number, from any customer, list it for 90 days), 422 NOT_A_MOBILE_NUMBER (a landline or VoIP destination, refused by the carrier before sending), 422 RESERVED_DESTINATION (a reserved exchange such as 555-XXXX, N11 or a carrier test code, refused here in both modes), 422 LINK_SHORTENER_BLOCKED, and the CASL codes. From a toll-free number: 403 TOLL_FREE_NOT_VERIFIED until the number's toll-free verification is approved (test keys too), and 451 DOUBLE_OPT_IN_REQUIRED until the recipient has replied YES to an opt-in confirmation from that number (POST /v1/compliance/opt-in-confirmations). A refused send costs nothing. 503 CARRIER_UNAVAILABLE means the carrier could not be reached; nothing was sent or charged, retry shortly. 503 CARRIER_THROTTLED means the carrier asked us to slow down and did not take the message: nothing was charged; retry after Retry-After with the same Idempotency-Key, which sends it. 503 CARRIER_TIMEOUT means the carrier did not answer in time: the message may or may not have been sent, nothing was charged, and a retry with the same Idempotency-Key returns the failed message rather than sending it again. Send an Idempotency-Key header from anything that retries: the same key returns the original message. A live send answers estimated_send_at, an estimate of when the carrier will send it.

Requires `messages:w`.

## Headers

- `idempotency-key` string

## Request body

- object
  - `from` string, required: Sending number (E.164, must be owned by account)
  - `to` string, required: Recipient number (E.164, Canadian numbers only)
  - `body` string, required: Up to 1600 characters and 10 SMS parts (about 1,530 GSM-7 or 670 Unicode characters). Longer bodies are rejected with 422 MESSAGE_TOO_LONG before any charge. Billed per part as the carrier splits it.
  - `skip_consent_check` boolean: Skip the CASL consent gate. Test-mode keys only: a live key gets 403 FORBIDDEN. Use only when you have consent recorded outside HonkIO.
  - `dncl_exemptions` string[]: DNCL exemption reasons. CRTC DNCL checking is coming soon and not yet enforced, so this field is currently accepted but not acted upon.

## Response `201`

Message sent (LIVE) or simulated (TEST). With an Idempotency-Key already used for the same from, to and body, the original message.

- SentMessage: The message a send created, with an estimate of when the carrier will send it.
  - `id` string, required
  - `direction` 'OUTBOUND' | 'INBOUND', required
  - `from` string, required
  - `to` string, required
  - `body` string, nullable, required: Null once the body is purged (see body_purged) and on a verification OTP, whose code is never stored.
  - `body_purged` boolean, required
  - `status` 'QUEUED' | 'SENDING' | 'SENT' | 'DELIVERED' | 'FAILED' | 'UNDELIVERED' | 'RECEIVED', required
  - `segment_count` integer, nullable, required: Number of SMS parts the carrier split the body into (inbound: as received). Billing is per part.
  - `mode` 'LIVE' | 'TEST', required: TEST messages are simulated: never sent to a carrier and never billed.
  - `is_verification` boolean, required: The OTP SMS behind a verification. Its body is never stored.
  - `auto_reply_keyword` 'STOP' | 'START' | 'HELP' | 'null', nullable, required: Set on the automatic confirmation sent when a recipient texts STOP, START or HELP. You did not send it and are never charged for it. Its body can be null for a few minutes after it is sent, until the carrier reports the text. Null on every other message.
  - `cost_cents` integer, required: What this message actually cost, in cents CAD: message_cost_cents times the parts the carrier split it into, settled to the carrier's count once it accepts the send (so it can differ from a pre-send estimate). 0 when the carrier refused the send outright (refunded or never charged), and on an automatic STOP/START/HELP confirmation (auto_reply_keyword), which is never charged. A message the carrier accepted but could not deliver keeps its charge. Inbound messages carry the inbound charge. On a TEST message this is what the send would have cost; nothing was charged.
  - `error_code` string, nullable, required: Set on FAILED and UNDELIVERED messages: the carrier's code from the delivery receipt (e.g. 30003), or the platform code for a synchronous rejection (CARRIER_ERROR, INVALID_PHONE_NUMBER, NOT_A_MOBILE_NUMBER, INSUFFICIENT_BALANCE). CARRIER_THROTTLED when the carrier asked us to slow down, or dropped the message from the sending number's full queue before trying the recipient: refunded, and safe to send again.
  - `error_message` string, nullable, required: Human-readable reason for a FAILED or UNDELIVERED message, from the carrier when it gave one.
  - `sent_at` string, date-time, nullable, required
  - `created_at` string, date-time, required
  - `casl_consent_type` 'EXPRESS' | 'IMPLIED' | 'null', nullable, required: The CASL consent an outbound send went out on. Null for inbound and test-mode messages, carrier-mandated STOP/HELP/START replies, and sends made before this was recorded (18 September 2026).
  - `dncl_exemption` string, nullable, required: The CRTC Do Not Call List exemption the send relied on, when one applied.
  - `delivered_at` string, date-time, nullable, required: When the carrier reported the message delivered to the handset.
  - `estimated_send_at` string, date-time, nullable, required: An estimate of when the carrier will start sending this message, from the parts already waiting on the sending number (a local number sends 10 parts a minute). Close to now when nothing is waiting. Null in test mode and on a retry that returned an earlier send.

## Other responses

- `401`: Missing, invalid, revoked or expired API key.
- `402`: INSUFFICIENT_BALANCE: the balance cannot cover the send. Also PAYMENT_REQUIRED when a LIVE key is used before the first top-up.
- `403`: FORBIDDEN (skip_consent_check on a live key, or the key lacks messages:w), ACCOUNT_NOT_VERIFIED, PHONE_NUMBER_NOT_OWNED, ALLOW_LIST_BLOCKED, DENY_LIST_BLOCKED, SENDING_PAUSED, or TOLL_FREE_NOT_VERIFIED (a toll-free from number without an approved toll-free verification; test keys too).
- `409`: PHONE_NUMBER_SUSPENDED (the from number is suspended for unpaid rent) or IDEMPOTENCY_KEY_REUSED (the key was used for a different from, to or body).
- `422`: VALIDATION_ERROR, NON_CANADIAN_NUMBER, RESERVED_DESTINATION, MESSAGE_TOO_LONG, LINK_SHORTENER_BLOCKED, UNDELIVERABLE_NUMBER, NOT_A_MOBILE_NUMBER or INVALID_PHONE_NUMBER (the carrier found the number invalid). A refused send costs nothing.
- `429`: DAILY_LIMIT_REACHED, FANOUT_LIMIT_REACHED, NUMBER_RATE_LIMITED or RECIPIENT_RATE_LIMITED (Retry-After set on the two rate limits), or RATE_LIMITED at 100 requests per second. NUMBER_RATE_LIMITED: the sending number already has a minute of message parts waiting for the carrier (a local number sends 10 parts a minute); wait Retry-After.
- `451`: The CASL gate refused the recipient: NO_CONSENT, OPT_OUT_BLOCKED or CONSENT_EXPIRED. From a toll-free number, also DOUBLE_OPT_IN_REQUIRED: the recipient has not yet replied YES to an opt-in confirmation from that number (POST /v1/compliance/opt-in-confirmations).
- `500`: Unexpected server error.
- `502`: CARRIER_ERROR: the carrier refused the send. Any charge is refunded.
- `503`: CARRIER_UNAVAILABLE: the carrier could not be reached. Nothing was sent or charged; retry shortly. CARRIER_THROTTLED: the carrier asked us to slow down and did not take the message. Nothing was charged; retry after Retry-After with the same Idempotency-Key, which sends it. CARRIER_TIMEOUT: the carrier did not answer in time, so the message may or may not have been sent. Nothing is charged. Check the message status before sending again: a retry with the same Idempotency-Key returns the failed message instead of sending it again.

---

[API](https://skmtc.dev/honkio/apis/honkio-api.md) · [All operations](https://skmtc.dev/honkio/apis/honkio-api/llms.txt) · [OpenAPI document](https://skmtc.dev/honkio/apis/honkio-api/revisions/ce4ed3fe9691?raw)
