API Docs

SMS API

Send your first Canadian SMS in under 5 minutes.

Authentication, API keys, webhook setup and signatures, rate limits, the SDK, the MCP server and the general error codes are on the Platform page.

Quickstart

  1. Create a free account, then open API Keys → Create key in the dashboard. The full key is shown once, right after you create it, so copy it then.
  2. Start with a test key: it works immediately, before any top-up, so you can send test messages and explore the API for free.
  3. Top up your balance with a card via Stripe. Live keys return 402 PAYMENT_REQUIRED until the first top-up.
  4. Verify your phone number as the account owner. A live send needs a verified owner phone.
  5. Buy a Canadian number to send from; a live send needs a number your account owns.
  6. Record CASL consent for each phone number you will message.
  7. Send your first message using the REST API.

Phone Numbers

Search for available Canadian numbers, provision one, and use it as the from field when sending.

bash
curl https://api.honkio.ca/v1/phone-numbers/search?area_codes=416 \
  -H "Authorization: Bearer mk_live_YOUR_KEY"

# Response 200 (per result): what buying it charges now and monthly, in CAD cents
# { "phone_number": "+14165550100", "region": "Ontario",
#   "upfront_cost_cents": 250, "activation_fee_cents": 100, "monthly_cost_cents": 250, ... }

# Provision a number
curl -X POST https://api.honkio.ca/v1/phone-numbers \
  -H "Authorization: Bearer mk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone_number": "+14165550100"}'

# Accounts hold a limited number of numbers. GET /v1/accounts/me reports
# phone_number_limit and phone_numbers_used: check them before buying, or
# handle the 403 NUMBER_LIMIT_REACHED that a purchase past the cap returns.

# Ask HonkIO staff to raise the limit. One request may be pending at a time.
curl -X POST https://api.honkio.ca/v1/phone-numbers/allowance-requests \
  -H "Authorization: Bearer mk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"requested_limit": 10, "reason": "Onboarding three new clinics this quarter"}'

# Response 201: { "status": "PENDING", "requested_limit": 10, ... }
# You are emailed if it is approved; the decision also shows in the dashboard.

# Check on it
curl https://api.honkio.ca/v1/phone-numbers/allowance-requests \
  -H "Authorization: Bearer mk_live_YOUR_KEY"

CASL Consent (required before sending)

Under CASL, you must record consent before sending a commercial message to any recipient. The API will block sends to phone numbers without valid consent (HTTP 451).

bash
curl -X POST https://api.honkio.ca/v1/compliance/consents \
  -H "Authorization: Bearer mk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+1613XXXXXXX",
    "consent_type": "express",
    "source_description": "Website opt-in form",
    "source_ip": "203.0.113.1"
  }'

Implied consent for an existing customer, with the clock running from their last transaction:

bash
curl -X POST https://api.honkio.ca/v1/compliance/consents \
  -H "Authorization: Bearer mk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+1613XXXXXXX",
    "consent_type": "implied",
    "relationship_type": "purchase",
    "last_transaction_date": "2025-11-04"
  }'

# → { "status": "recorded", "phone_number": "+1613XXXXXXX", "expires_at": "2027-11-04T00:00:00.000Z" }

Express consent never expires. Implied consent expires after 2 years per CASL §10(9). Pass last_transaction_date so the two-year clock runs from the real relationship rather than from the day you record it, or set expires_at outright when you have already worked out the expiry. The response echoes expires_at so you can verify it.

Sending SMS

Send a message using a provisioned number. The API validates the Canadian destination number, and checks CASL consent before delivery (CRTC DNCL checking coming soon).

bash
# "from" is one of your HonkIO numbers; "to" is a real number you hold consent for
curl -X POST https://api.honkio.ca/v1/messages \
  -H "Authorization: Bearer mk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+1416XXXXXXX",
    "to":   "+1613XXXXXXX",
    "body": "Hello from HonkIO! 🇨🇦"
  }'

Phone Number Verification (OTP)

Use the Verify API to confirm ownership of a phone number before sending commercial messages. Your end-user receives a one-time code via SMS; submit it to the check endpoint to confirm.

bash
# Start a verification (sends OTP SMS)
curl -X POST https://api.honkio.ca/v1/verify   -H "Authorization: Bearer mk_live_YOUR_KEY"   -H "Content-Type: application/json"   -d '{
    "from": "+1416XXXXXXX",
    "to":   "+1613XXXXXXX",
    "code_length": 6,
    "ttl_minutes": 10,
    "app_name": "Acme"
  }'
# Response: { "id": "clxxx...", "status": "pending", "code_length": 6, ... }

# Check the code submitted by your user
curl -X POST https://api.honkio.ca/v1/verify/clxxx.../check   -H "Authorization: Bearer mk_live_YOUR_KEY"   -H "Content-Type: application/json"   -d '{ "code": "483721" }'
# Response 200: { "status": "verified", ... }
# Response 422: { "code": "VERIFICATION_INVALID_CODE", "attempts_remaining": 4 }

# Fetch status at any time
curl https://api.honkio.ca/v1/verify/clxxx...   -H "Authorization: Bearer mk_live_YOUR_KEY"

In test mode the code is always all zeros for the chosen length (e.g. 000000 for 6-digit). No SMS is sent and nothing is billed. Every verification returns a "mode" field of "LIVE" or "TEST" so you can tell a simulated verification from a real one.

Pricing

Prices are set at runtime and can change without a release, so read them rather than hardcoding them. All amounts are CAD cents. Sending is billed per SMS part: message_cost_cents × the parts the carrier splits the body into. Parts are counted the way the carrier counts them: typographic quotes, dashes and ellipses are smart-encoded to GSM-7 (160 characters, then 153 per part), while emoji and most accented letters force Unicode parts (70, then 67). The charge is settled to the carrier's part count after the send. A message the carrier rejects outright costs nothing, and so does a send to a reserved exchange (555-XXXX and similar), which is refused here; a message the carrier accepts but cannot deliver keeps its charge. A body over 10 parts is refused with 422 MESSAGE_TOO_LONG before any charge. verification_cost_cents covers a typical single-part OTP; a long or non-GSM app_name can add a part. phone_number_activation_fee_cents is charged once, together with the first month, on every number provisioned, local or toll-free, and is not refunded on release. inbound_message_cost_cents is charged per part of every SMS received on a provisioned number, sender and carrier included, except STOP, START and HELP keywords; a received message is debited even if it takes the balance below zero, which pauses sending until the next top-up.

bash
# Current prices, in CAD cents
curl https://api.honkio.ca/v1/pricing \
  -H "Authorization: Bearer mk_live_YOUR_KEY"

# Response 200:
# {
#   "message_cost_cents": 3,
#   "verification_upcharge_cents": 25,
#   "verification_cost_cents": 28,
#   "phone_number_upfront_cost_cents": 250,
#   "phone_number_monthly_cost_cents": 250,
#   "phone_number_activation_fee_cents": 100,
#   "inbound_message_cost_cents": 3
# }

Test-mode requests are priced identically in the response but never charged, so you can see what an integration would cost before spending anything.

Sending limits

HonkIO is built for transactional and relationship messaging, not campaigns, and every customer’s deliverability rides on a shared carrier profile. These limits keep bulk marketing off the platform; a clinic, a contractor or a SaaS sending codes will not notice them. The sending limits apply to live mode only; the link-shortener rule, the reserved and undeliverable destination checks, and the broadcast group-size cap run in both modes.

  • Daily cap: new accounts can send 250 live messages per rolling 24 hours. It does not lift on its own. 30 days after your first live message you can request a higher volume from the dashboard; approval sets 1,000 a day or the figure you asked for. Refused sends return 429 DAILY_LIMIT_REACHED with your limit and count.
  • Identical messages: the same body may reach at most 250 distinct recipients per 24 hours (429 FANOUT_LIMIT_REACHED). Personalized messages are unaffected.
  • Per-number rate: 60 messages per minute per sending number, which is what Canadian carriers grant a long code anyway (429 NUMBER_RATE_LIMITED with Retry-After).
  • Broadcasts: up to 250 recipients per contact-group broadcast and 3 broadcasts per 24 hours (422 BROADCAST_TOO_LARGE, 429 BROADCAST_LIMIT_REACHED).
  • Link shorteners (bit.ly, tinyurl and similar) are refused in both modes because carriers filter them (422 LINK_SHORTENER_BLOCKED). Use the full URL.
  • Delivery warning, then automatic pause: if more than 10% of your last 50 live messages fail at the carrier, you are emailed (and account.delivery_warning fires) without any pause. If more than 1% of recipients reply STOP, or more than 5% of messages are rejected by carriers, over your recent sends, live sending pauses for 24 hours and you are emailed (403 SENDING_PAUSED with the resume time; account.sending_paused fires).
  • Top-ups: the balance cannot exceed $500 and top-ups are limited to $1,000 per 30 days. Raised on request.
  • Per-recipient cap: 30 messages to one recipient per hour and 100 per 24 hours (429 RECIPIENT_RATE_LIMITED with Retry-After). A two-way conversation never gets near it; a script retrying one number does.
  • Undelivered messages are billed: a message the carrier accepts but cannot deliver keeps its charge. A reserved exchange (555-XXXX, N11 exchanges such as 411 or 911, carrier test codes, exchanges starting with 0 or 1) is refused for free with 422 RESERVED_DESTINATION, in test mode too, and a reserved area code (555, 911 and the like) as non-Canadian. Anything that retries should send an Idempotency-Key header so a retry can never become a second charge. An hour of unusual spend triggers an email and account.spend_warning.
  • Undeliverable numbers: after 3 consecutive carrier failures to one number within 30 days, from any HonkIO customer, sends to it are refused without charge (422 UNDELIVERABLE_NUMBER with the failure count, when it was listed and when it expires) for 90 days, then retried in case the number was reassigned.
bash
curl https://api.honkio.ca/v1/send-limit \
  -H "Authorization: Bearer mk_live_YOUR_KEY"

# → { "daily_limit": 250, "sent_last_24h": 12, "remaining": 238,
#     "recipient_rate_per_hour": 30, "recipient_rate_per_day": 100,
#     "probation": { "ends_at": "2026-09-26T14:02:11.000Z", "eligible_to_request": false },
#     "paused_until": null, "requests": [] }

Read your current limits and usage with GET /v1/send-limit, and file a request with POST /v1/send-limit/requests. Every figure above is a platform default that can be raised per account.

Webhook events

These are the SMS, opt-out and phone number events. Registering an endpoint, the envelope every event arrives in, retries and signatures are covered under Platform webhooks.

Every payload is signed with HMAC-SHA256, so verify the X-HonkIO-Signature header. A message.received event carries the sender, your number, the body, keyword_action (STOP/START handling), and the segment_count and cost_cents the message was billed at. A message.sent event carries carrier_message_id, the id the carrier assigned to the message, with the same two billing fields.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "message.delivered",
  "created": "2026-09-19T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "message_id": "clxxxmessagexxxxxxxxxxxxx",
    "status": "delivered",
    "message_status": "DELIVERED",
    "to": "+1613XXXXXXX",
    "from": "+1416XXXXXXX",
    "errors": []
  }
}
// Headers: X-HonkIO-Signature, X-HonkIO-Timestamp, X-HonkIO-Event

A message id can emit message.failed (only when the failure was CARRIER_UNAVAILABLE; an INSUFFICIENT_BALANCE failure fires no webhook) and later message.queued again, then message.sent, when a retry with the same Idempotency-Key re-attempts a message that failed before it reached the carrier.

Every event, as your endpoint receives it. The examples are redacted: ids, numbers and addresses are placeholders. Fields can be added over time, so ignore any you do not recognise.

message.queued

An outbound message was accepted and charged, and is about to be handed to the carrier.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "message.queued",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "message_id": "clxxxmessagexxxxxxxxxxxxx",
    "from": "+1416XXXXXXX",
    "to": "+1613XXXXXXX",
    "status": "QUEUED",
    "message_status": "QUEUED"
  }
}
data fieldMeaning
message_idThe HonkIO message id, as returned by POST /v1/messages or listed by GET /v1/messages.
fromYour HonkIO number the message was sent from, E.164.
toThe recipient's phone number, E.164.
statusThe message status at this point, upper case (the same value as message_status).
message_statusHonkIO's status for the message, upper case: QUEUED, SENDING, DELIVERED, FAILED, UNDELIVERED or RECEIVED.

message.sending

Reserved: rarely if ever sent. It fires only when a final carrier receipt reports the sending status, which is not expected in practice.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "message.sending",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "message_id": "clxxxmessagexxxxxxxxxxxxx",
    "status": "sending",
    "message_status": "SENDING",
    "to": "+1613XXXXXXX",
    "from": "+1416XXXXXXX",
    "errors": []
  }
}
data fieldMeaning
message_idThe HonkIO message id, as returned by POST /v1/messages or listed by GET /v1/messages.
statusThe carrier's own status from its receipt, lower case (for example delivered, sending_failed, delivery_failed or expired).
message_statusHonkIO's status for the message, upper case: QUEUED, SENDING, DELIVERED, FAILED, UNDELIVERED or RECEIVED.
toThe recipient's phone number, E.164.
fromYour HonkIO number the message was sent from, E.164.
errorsWhat the carrier reported, as a list; empty when it reported nothing.
errors[].codeThe carrier's error code, as given.
errors[].titleA short description of the error.
errors[].detailMore detail, when the carrier gave any.

message.sent

The carrier accepted the message for delivery. Fired when the send call returns, not from a receipt.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "message.sent",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "message_id": "clxxxmessagexxxxxxxxxxxxx",
    "from": "+1416XXXXXXX",
    "to": "+1613XXXXXXX",
    "status": "SENDING",
    "message_status": "SENDING",
    "carrier_message_id": "40319xxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "segment_count": 1,
    "cost_cents": 3
  }
}
data fieldMeaning
message_idThe HonkIO message id, as returned by POST /v1/messages or listed by GET /v1/messages.
fromYour HonkIO number the message was sent from, E.164.
toThe recipient's phone number, E.164.
statusThe message status at this point, upper case (the same value as message_status).
message_statusHonkIO's status for the message, upper case: QUEUED, SENDING, DELIVERED, FAILED, UNDELIVERED or RECEIVED.
carrier_message_idThe id the carrier assigned to the message, for support requests.
segment_countHow many SMS segments the message was split into, which is what it was billed per.
cost_centsWhat the message cost you, in Canadian cents.

message.delivered

The carrier's receipt says the message reached the handset.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "message.delivered",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "message_id": "clxxxmessagexxxxxxxxxxxxx",
    "status": "delivered",
    "message_status": "DELIVERED",
    "to": "+1613XXXXXXX",
    "from": "+1416XXXXXXX",
    "errors": []
  }
}
data fieldMeaning
message_idThe HonkIO message id, as returned by POST /v1/messages or listed by GET /v1/messages.
statusThe carrier's own status from its receipt, lower case (for example delivered, sending_failed, delivery_failed or expired).
message_statusHonkIO's status for the message, upper case: QUEUED, SENDING, DELIVERED, FAILED, UNDELIVERED or RECEIVED.
toThe recipient's phone number, E.164.
fromYour HonkIO number the message was sent from, E.164.
errorsWhat the carrier reported, as a list; empty when it reported nothing.
errors[].codeThe carrier's error code, as given.
errors[].titleA short description of the error.
errors[].detailMore detail, when the carrier gave any.

message.failed

The message did not go out. Three shapes depending on where it failed: a failure at hand off to the carrier (CARRIER_UNAVAILABLE, CARRIER_TIMEOUT, CARRIER_ERROR, INVALID_PHONE_NUMBER, NOT_A_MOBILE_NUMBER) carries error_code and error_message but no errors array; a carrier receipt carries the carrier's raw status, an errors array and an error_code, but no error_message; a send interrupted by a server restart (error_code STALE_QUEUED) carries neither to nor from. A failure for INSUFFICIENT_BALANCE fires no webhook.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "message.failed",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "message_id": "clxxxmessagexxxxxxxxxxxxx",
    "from": "+1416XXXXXXX",
    "to": "+1613XXXXXXX",
    "status": "FAILED",
    "message_status": "FAILED",
    "error_code": "INVALID_PHONE_NUMBER",
    "error_message": "The destination is not a valid phone number."
  }
}
data fieldMeaning
message_idThe HonkIO message id, as returned by POST /v1/messages or listed by GET /v1/messages.
statusThe carrier's own status from its receipt, lower case (for example delivered, sending_failed, delivery_failed or expired).
message_statusHonkIO's status for the message, upper case: QUEUED, SENDING, DELIVERED, FAILED, UNDELIVERED or RECEIVED.
toThe recipient's phone number, E.164.
fromYour HonkIO number the message was sent from, E.164.
error_codeHonkIO's error code for the failure, the same one the message shows in the API.
error_messageA readable explanation of error_code.
errorsWhat the carrier reported, as a list; empty when it reported nothing.
errors[].codeThe carrier's error code, as given.
errors[].titleA short description of the error.
errors[].detailMore detail, when the carrier gave any.

message.undelivered

The carrier took the message but could not deliver it: blocked, expired, or the handset was unreachable.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "message.undelivered",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "message_id": "clxxxmessagexxxxxxxxxxxxx",
    "status": "delivery_failed",
    "message_status": "UNDELIVERED",
    "to": "+1613XXXXXXX",
    "from": "+1416XXXXXXX",
    "errors": [
      {
        "code": "40002",
        "title": "Blocked as spam",
        "detail": "The destination carrier blocked the message."
      }
    ]
  }
}
data fieldMeaning
message_idThe HonkIO message id, as returned by POST /v1/messages or listed by GET /v1/messages.
statusThe carrier's own status from its receipt, lower case (for example delivered, sending_failed, delivery_failed or expired).
message_statusHonkIO's status for the message, upper case: QUEUED, SENDING, DELIVERED, FAILED, UNDELIVERED or RECEIVED.
toThe recipient's phone number, E.164.
fromYour HonkIO number the message was sent from, E.164.
errorsWhat the carrier reported, as a list; empty when it reported nothing.
errors[].codeThe carrier's error code, as given.
errors[].titleA short description of the error.
errors[].detailMore detail, when the carrier gave any.

message.received

Someone texted one of your HonkIO numbers. STOP, START and HELP replies arrive here too, with keyword_action saying what was done about them.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "message.received",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "message_id": "clxxxmessagexxxxxxxxxxxxx",
    "from": "+1613XXXXXXX",
    "to": "+1416XXXXXXX",
    "body": "Yes, see you at 3",
    "keyword_action": "ignored",
    "segment_count": 1,
    "cost_cents": 1,
    "message_status": "RECEIVED"
  }
}
data fieldMeaning
message_idThe HonkIO message id, as returned by POST /v1/messages or listed by GET /v1/messages.
fromThe phone number that texted you, E.164.
toYour HonkIO number that received the text, E.164.
bodyThe text of the message.
keyword_actionWhat HonkIO did about a compliance keyword, judged by the first word of the text: opted_out (STOP and the like, and an opt_out.recorded event follows), reinstated (START or UNSTOP after an opt out, and an opt_out.reinstated event follows), help (HELP, INFO or AIDE; the automatic reply was sent), or ignored.
segment_countHow many SMS segments the message was split into, which is what it was billed per.
cost_centsWhat receiving the message cost you, in Canadian cents.
message_statusHonkIO's status for the message, upper case: QUEUED, SENDING, DELIVERED, FAILED, UNDELIVERED or RECEIVED.

opt_out.recorded

A subscriber replied to one of your numbers with an opt out keyword (a reply whose first word is STOP, STOPALL, UNSUBSCRIBE, CANCEL, END or QUIT), and messages to them from that number are now blocked. Fires only for a keyword reply: an opt out you record with POST /v1/compliance/opt-outs does not fire it.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "opt_out.recorded",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "phone_number": "+1613XXXXXXX",
    "from_number": "+1416XXXXXXX",
    "keyword": "STOP"
  }
}
data fieldMeaning
phone_numberThe subscriber who texted the keyword, E.164: the person to stop or resume messaging.
from_numberYour HonkIO number that received the keyword, E.164. An opt out applies to messages from this number.
keywordThe subscriber's reply, trimmed and upper case, e.g. STOP or STOP PLEASE. The whole reply, not only the keyword: it was recognised by its first word.

opt_out.reinstated

A subscriber who had opted out replied with START or UNSTOP, so you may message them again from that number. Fires only for a keyword reply, and only when an opt out existed: a START from someone who never opted out is an ordinary message.received.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "opt_out.reinstated",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "phone_number": "+1613XXXXXXX",
    "from_number": "+1416XXXXXXX",
    "keyword": "START"
  }
}
data fieldMeaning
phone_numberThe subscriber who texted the keyword, E.164: the person to stop or resume messaging.
from_numberYour HonkIO number that received the keyword, E.164. An opt out applies to messages from this number.
keywordThe subscriber's reply, trimmed and upper case, e.g. START. The whole reply, not only the keyword: it was recognised by its first word.

phone_number.suspended

Monthly rent could not be charged, so the number was suspended. Top up to bring it back before the grace period ends.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "phone_number.suspended",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "phone_number": "+1416XXXXXXX",
    "reason": "insufficient_balance",
    "monthly_cost_cents": 299
  }
}
data fieldMeaning
phone_numberYour HonkIO number, E.164.
reasonAlways insufficient_balance: the month's rent could not be charged.
monthly_cost_centsThe number's monthly rent, in Canadian cents.

phone_number.released

The number has left your account and cannot be recovered. Stop routing to it.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "phone_number.released",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "phone_number": "+1416XXXXXXX",
    "reason": "customer_released"
  }
}
data fieldMeaning
phone_numberYour HonkIO number, E.164.
reasonsuspended_grace_expired when a suspension ran past its grace period, customer_released when you released it yourself, admin_released when HonkIO staff released it.

Error codes

The codes below are specific to SMS, phone numbers, verification and CASL. Any call can also answer with the general codes in the platform error table, such as VALIDATION_ERROR, INSUFFICIENT_BALANCE and NOT_FOUND.

HTTPCodeMeaning
403NUMBER_LIMIT_REACHEDAccount has reached its phone number limit
403PHONE_NUMBER_NOT_OWNEDThe from number is not provisioned on this account.
403ALLOW_LIST_BLOCKEDRecipient is not on the API key's ALLOW list
403DENY_LIST_BLOCKEDRecipient is on the API key's DENY list
403SEND_LIMIT_REQUEST_TOO_EARLYVolume requests open once the account's probation period is over.
404VERIFICATION_NOT_FOUNDVerification ID not found or not owned by this account
404CONTACT_NOT_FOUNDContact not found
404CONTACT_GROUP_NOT_FOUNDContact group not found
404CONTACT_GROUP_MEMBER_NOT_FOUNDThat contact is not a member of this group
404CONTACT_LIST_NOT_FOUNDContact list not found
404CONTACT_LIST_ENTRY_NOT_FOUNDContact list entry not found
409VERIFICATION_ALREADY_VERIFIEDThis number has already been verified
409PURCHASE_IN_PROGRESSAnother number purchase is in flight. Retry shortly
409ALLOWANCE_REQUEST_PENDINGAn allowance request is already awaiting review
409PHONE_NUMBER_SUSPENDEDThis number's monthly charge could not be collected. Top up to reactivate it.
409SEND_LIMIT_REQUEST_PENDINGA volume request is already awaiting review.
409IDEMPOTENCY_KEY_REUSEDThe same Idempotency-Key was sent with a different to, from or body.
409CONTACT_ALREADY_EXISTSA contact with that phone number already exists
409CONTACT_GROUP_ALREADY_EXISTSA contact group with that name already exists
409CONTACT_GROUP_MEMBER_EXISTSContact is already a member of this group
409CONTACT_LIST_ALREADY_EXISTSA contact list with that name already exists
409CONTACT_LIST_ENTRY_EXISTSThis entry already exists in the list
410VERIFICATION_EXPIREDThe verification code has expired
422NON_CANADIAN_NUMBERNot a valid Canadian E.164 number
422UNDELIVERABLE_NUMBERThe number failed at the carrier three times running (for any customer); refused without charge for 90 days.
422NOT_A_MOBILE_NUMBERThe destination is a landline or VoIP number. The carrier refuses it before sending; nothing is charged.
422RESERVED_DESTINATIONThe destination is in a reserved exchange (555-XXXX, N11, carrier test codes). Refused before anything is sent; nothing is charged.
422MESSAGE_TOO_LONGBody would exceed the carrier limit of 10 SMS parts (≈1,530 GSM-7 or 670 Unicode characters). Nothing is charged
422VERIFICATION_INVALID_CODEIncorrect code. attempts_remaining shows how many tries are left
422INVALID_ALLOWANCE_REQUESTRequested allowance must exceed your current limit
422LINK_SHORTENER_BLOCKEDLink shorteners are refused because carriers filter them. Use the full URL
422BROADCAST_TOO_LARGEContact group too large for one broadcast (250 members by default). Split it and send in batches
422CANNOT_ERASE_OWN_NUMBERThat number belongs to your account. Erasure is only for a subscriber's number
422TOO_MANY_AREA_CODESSearch at most 25 area codes at a time
422INVALID_PHONE_NUMBERNot a valid E.164 phone number
422CONTACT_LIST_ENTRY_INVALIDEach entry must specify exactly one of phone_number, contact_id or contact_group_id
429RECIPIENT_RATE_LIMITEDMore than 30 messages to one recipient in an hour or 100 in a day; retry after the Retry-After header.
429VERIFICATION_MAX_ATTEMPTSToo many wrong attempts. This verification is locked
429DAILY_LIMIT_REACHEDDaily sending limit reached (details show your limit and count). Request a higher volume once eligible
429FANOUT_LIMIT_REACHEDThis exact message already reached the maximum number of distinct recipients allowed in 24 hours
429NUMBER_RATE_LIMITEDThis sending number reached its per-minute limit. Retry shortly
429BROADCAST_LIMIT_REACHEDBroadcast limit reached for this account in the last 24 hours
451OPT_OUT_BLOCKEDRecipient has opted out, so the send is legally blocked
451NO_CONSENTNo valid CASL consent on file
451CONSENT_EXPIREDImplied consent expired (2-year CASL limit)
451DNCL_BLOCKEDNumber on the CRTC DNCL with no exemption (coming soon; not currently returned)
501DNCL_COMING_SOONCRTC Do Not Call List checking is not available yet.
501TOLLFREE_800_COMING_SOON1-800 numbers are not available yet. Choose another toll-free prefix or a local number.
502CARRIER_ERRORThe carrier returned an error while sending.
502PROVISIONING_FAILEDThe carrier could not complete this number purchase. Nothing was charged.
502NUMBER_SEARCH_FAILEDNumber search is temporarily unavailable.
502RELEASE_FAILEDThe carrier did not accept the release. The number is still yours; retry shortly
503CARRIER_UNAVAILABLEThe carrier could not be reached; nothing was sent or charged. Retry shortly.
503CARRIER_TIMEOUTThe 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.