Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: HonkIO Messages API
description: 'Canadian-focused SMS gateway API.
## Canadian Compliance
- **CASL**: All commercial messages require consent. Use `/v1/compliance/consents` to record.
- **DNCL**: CRTC Do Not Call List checking is coming soon (not yet enforced).
- **PIPEDA / Quebec Law 25**: Data is stored in Canada (ca-central-1). Requests are processed in the United States in transit. Right-to-erasure available.
## Authentication
Provide your API key via `Authorization: Bearer mk_live_...` or `X-API-Key: mk_live_...`.
Test keys never send, never charge and can''t change live data; they work before your first top-up.
## Rate limits
100 requests per second per account. Every authenticated response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.
## Pagination
Shape follows the route family, not one convention across the API: documented here rather than changed. Messages listings return `{ data, meta: { page, limit, total, pages } }`. Contacts, Contact Groups, Lists and Verify listings return `{ data, total, limit, offset }`. Compliance consent and opt-out listings accept `page`/`limit` and return `{ data }`, with no total. Account transactions use a cursor: pass `before`, get back `has_more`.'
version: 1.0.0
contact:
name: HonkIO Support
email: support@honkio.ca
servers:
- url: https://api.honkio.ca
description: Production
- url: http://localhost:3000
description: Local development
security:
- apiKey: []
- apiKeyHeader: []
tags:
- name: Messages
description: Send and receive SMS messages
paths:
/v1/messages:
post:
summary: Send an SMS message
tags:
- Messages
description: '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`.'
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- from
- to
- body
properties:
from:
type: string
description: Sending number (E.164, must be owned by account)
to:
type: string
description: Recipient number (E.164, Canadian numbers only)
body:
type: string
maxLength: 1600
description: 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:
type: boolean
description: '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:
type: array
items:
type: string
enum:
- existing_business_relationship
- registered_charity
- political
- survey
- newspaper_subscription
- personal
description: DNCL exemption reasons. CRTC DNCL checking is coming soon and not yet enforced, so this field is currently accepted but not acted upon.
parameters:
- schema:
type: string
maxLength: 255
in: header
name: idempotency-key
required: false
security:
- apiKey: []
- apiKeyHeader: []
x-honkio-permission: messages:w
responses:
'201':
description: Message sent (LIVE) or simulated (TEST). With an Idempotency-Key already used for the same from, to and body, the original message.
content:
application/json:
schema:
$ref: '#/components/schemas/SentMessage'
'401':
description: Missing, invalid, revoked or expired API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'402':
description: 'INSUFFICIENT_BALANCE: the balance cannot cover the send. RENT_OWED: rent is owed on a number released while suspended; top up to pay it first. Also PAYMENT_REQUIRED when a LIVE key is used before the first top-up.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: 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).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'409':
description: 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).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'422':
description: 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.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: '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.'
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'451':
description: '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).'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Unexpected server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'502':
description: 'CARRIER_ERROR: the carrier refused the send. Any charge is refunded.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
description: '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.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
operationId: postV1Messages
x-operation-id-source: derived
get:
summary: List messages
tags:
- Messages
description: Requires `messages:r`.
parameters:
- schema:
type: string
in: query
name: q
required: false
description: Search by phone number (partial match on to/from)
- schema:
type: string
in: query
name: from
required: false
- schema:
type: string
in: query
name: to
required: false
- schema:
type: string
enum:
- QUEUED
- SENDING
- SENT
- DELIVERED
- FAILED
- UNDELIVERED
- RECEIVED
in: query
name: status
required: false
- schema:
type: string
enum:
- OUTBOUND
- INBOUND
in: query
name: direction
required: false
- schema:
type: integer
minimum: 1
default: 1
in: query
name: page
required: false
- schema:
type: integer
minimum: 1
maximum: 100
default: 20
in: query
name: limit
required: false
- schema:
type: string
format: date
in: query
name: date_from
required: false
- schema:
type: string
format: date
in: query
name: date_to
required: false
security:
- apiKey: []
- apiKeyHeader: []
x-honkio-permission: messages:r
responses:
'200':
description: A page of messages, newest first.
content:
application/json:
schema:
description: A page of messages, newest first.
type: object
additionalProperties: true
required:
- data
- meta
properties:
data:
type: array
items:
$ref: '#/components/schemas/Message'
meta:
$ref: '#/components/schemas/PaginationMeta'
'401':
description: Missing, invalid, revoked or expired API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'402':
description: The account has no balance, or a LIVE key was used before the first top-up.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the permission this operation requires, or the account is suspended or closed.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'422':
description: 'VALIDATION_ERROR: the request failed validation; details names the fields.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: 'Rate limit reached: 100 requests per second per account, or a route-specific limit (Retry-After set where one applies).'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Unexpected server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
operationId: getV1Messages
x-operation-id-source: derived
/v1/messages/{id}:
get:
summary: Retrieve a message by ID
tags:
- Messages
description: Requires `messages:r`.
parameters:
- schema:
type: string
in: path
name: id
required: true
security:
- apiKey: []
- apiKeyHeader: []
x-honkio-permission: messages:r
responses:
'200':
description: The message.
content:
application/json:
schema:
$ref: '#/components/schemas/Message'
'401':
description: Missing, invalid, revoked or expired API key.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'402':
description: The account has no balance, or a LIVE key was used before the first top-up.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'403':
description: The API key lacks the permission this operation requires, or the account is suspended or closed.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: 'NOT_FOUND: no such message on this account. A test key finds TEST messages only.'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: 'Rate limit reached: 100 requests per second per account, or a route-specific limit (Retry-After set where one applies).'
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Unexpected server error.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
operationId: getV1MessagesById
x-operation-id-source: derived
components:
schemas:
SentMessage:
type: object
additionalProperties: true
required:
- id
- direction
- from
- to
- body
- body_purged
- status
- segment_count
- mode
- is_verification
- auto_reply_keyword
- cost_cents
- error_code
- error_message
- sent_at
- created_at
- casl_consent_type
- dncl_exemption
- delivered_at
- estimated_send_at
properties:
id:
type: string
direction:
type: string
enum:
- OUTBOUND
- INBOUND
from:
type: string
to:
type: string
body:
type:
- string
- 'null'
description: Null once the body is purged (see body_purged) and on a verification OTP, whose code is never stored.
body_purged:
type: boolean
status:
type: string
enum:
- QUEUED
- SENDING
- SENT
- DELIVERED
- FAILED
- UNDELIVERED
- RECEIVED
segment_count:
type:
- integer
- 'null'
description: 'Number of SMS parts the carrier split the body into (inbound: as received). Billing is per part.'
mode:
type: string
enum:
- LIVE
- TEST
description: 'TEST messages are simulated: never sent to a carrier and never billed.'
is_verification:
type: boolean
description: The OTP SMS behind a verification. Its body is never stored.
auto_reply_keyword:
type:
- string
- 'null'
enum:
- STOP
- START
- HELP
- null
description: 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:
type: integer
description: '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:
type:
- string
- 'null'
description: '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, RENT_OWED). 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. CARRIER_LOST when the carrier accepted the message and then lost it: no receipt ever came, and three days later the carrier had no record of it; refunded, and safe to send again with a new Idempotency-Key.'
error_message:
type:
- string
- 'null'
description: Human-readable reason for a FAILED or UNDELIVERED message, from the carrier when it gave one.
sent_at:
type:
- string
- 'null'
format: date-time
created_at:
type: string
format: date-time
casl_consent_type:
type:
- string
- 'null'
enum:
- EXPRESS
- IMPLIED
- null
description: 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:
type:
- string
- 'null'
description: The CRTC Do Not Call List exemption the send relied on, when one applied.
delivered_at:
type:
- string
- 'null'
format: date-time
description: When the carrier reported the message delivered to the handset.
estimated_send_at:
type:
- string
- 'null'
format: date-time
description: 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.
description: The message a send created, with an estimate of when the carrier will send it.
Message:
type: object
additionalProperties: true
required:
- id
- direction
- from
- to
- body
- body_purged
- status
- segment_count
- mode
- is_verification
- auto_reply_keyword
- cost_cents
- error_code
- error_message
- sent_at
- created_at
- casl_consent_type
- dncl_exemption
- delivered_at
properties:
id:
type: string
direction:
type: string
enum:
- OUTBOUND
- INBOUND
from:
type: string
to:
type: string
body:
type:
- string
- 'null'
description: Null once the body is purged (see body_purged) and on a verification OTP, whose code is never stored.
body_purged:
type: boolean
status:
type: string
enum:
- QUEUED
- SENDING
- SENT
- DELIVERED
- FAILED
- UNDELIVERED
- RECEIVED
segment_count:
type:
- integer
- 'null'
description: 'Number of SMS parts the carrier split the body into (inbound: as received). Billing is per part.'
mode:
type: string
enum:
- LIVE
- TEST
description: 'TEST messages are simulated: never sent to a carrier and never billed.'
is_verification:
type: boolean
description: The OTP SMS behind a verification. Its body is never stored.
auto_reply_keyword:
type:
- string
- 'null'
enum:
- STOP
- START
- HELP
- null
description: 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:
type: integer
description: '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:
type:
- string
- 'null'
description: '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, RENT_OWED). 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. CARRIER_LOST when the carrier accepted the message and then lost it: no receipt ever came, and three days later the carrier had no record of it; refunded, and safe to send again with a new Idempotency-Key.'
error_message:
type:
- string
- 'null'
description: Human-readable reason for a FAILED or UNDELIVERED message, from the carrier when it gave one.
sent_at:
type:
- string
- 'null'
format: date-time
created_at:
type: string
format: date-time
casl_consent_type:
type:
- string
- 'null'
enum:
- EXPRESS
- IMPLIED
- null
description: 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:
type:
- string
- 'null'
description: The CRTC Do Not Call List exemption the send relied on, when one applied.
delivered_at:
type:
- string
- 'null'
format: date-time
description: When the carrier reported the message delivered to the handset.
description: An SMS message, outbound or inbound. The same shape from the send, the lookup and the list.
PaginationMeta:
type: object
additionalProperties: true
required:
- page
- limit
- total
- pages
properties:
page:
type: integer
limit:
type: integer
total:
type: integer
pages:
type: integer
description: Page-number pagination, returned as `meta` by the messages listing.
Error:
type: object
description: Standard error body. The one visible exception is the OAuth 2.1 surface (/oauth/token, /oauth/register, /oauth/revoke), which follows RFC 6749/7591/7009 and answers { error, error_description } instead.
required:
- code
- message
- statusCode
properties:
code:
type: string
description: Machine-readable error code, e.g. VALIDATION_ERROR.
message:
type: string
description: Human-readable message in the request's locale (Accept-Language).
messageEn:
type: string
description: English message, always present alongside message.
messageFr:
type: string
description: French message, always present alongside message.
statusCode:
type: integer
description: The HTTP status, repeated in the body.
details:
description: 'Present on some errors: an AJV validation array, or error-specific structured detail.'
securitySchemes:
apiKey:
type: http
scheme: bearer
bearerFormat: API Key
description: Use `mk_live_...` for production or `mk_test_...` for testing.
apiKeyHeader:
type: apiKey
in: header
name: X-API-Key
description: 'Alternative to the Authorization header: send `mk_live_...` or `mk_test_...` as X-API-Key directly.'