API Docs

Platform

Authentication, API keys, webhooks, rate limits, errors and the tools that work across the whole API.

Authentication

All API requests require a Bearer token in the Authorization header. Use test keys (mk_test_...) for development: no real SMS are sent and nothing is charged. Use live keys (mk_live_...) for production.

⚠️ Never expose API keys in client-side code or public repositories.

Test mode

A test key (mk_test_...) works from the moment your account is created, before any top-up. New accounts start PENDING_PAYMENT: a live key gets 402 PAYMENT_REQUIRED for everything except a few onboarding reads (GET /v1/accounts/me, GET /v1/accounts/:id, GET /v1/accounts/:id/topup-allowance and GET /v1/pricing); test keys pass regardless.

A test send runs the same recipient checks a live send does: CASL consent and opt-out, the API key's allow and deny lists, the reserved-exchange and undeliverable-number checks, and the Canadian-destination check. It skips owning the from number, owner phone verification, your balance, the sending limits, and the DNCL check (not enforced on any send yet). No message reaches a carrier: it is priced exactly like a live one, so you see the real cost, then the row is marked delivered.

A test key is a sandbox with no live reach: it cannot buy or release phone numbers, mint, rotate or revoke live keys, send the owner phone verification code, run right-to-erasure, change webhooks, file a volume or phone-number allowance request, or change a live key's lists or default-deny, and it cannot read the dead-letter queue. Message and verification history through a test key show test rows only. Contacts, groups, lists, consents and opt-outs stay writable, except one a live key's allow or deny list references: changing that needs a live key too.

API keys & permissions

Every API key carries a permissions object, one string of letters per resource: r (read, GET), w (write, POST), m (modify, PATCH or PUT), d (delete, DELETE). A missing resource or an empty string means no access to it. You can also send the key as an X-API-Key header instead of an Authorization Bearer token. A few routes need a less obvious letter: POST /v1/compliance/erasure needs compliance:d; POST /v1/accounts/:id/phone-verification and its /confirm need account:m; POST /v1/compliance/dncl/check needs compliance:r; DELETE /v1/contact-groups/:id/members/:contactId needs contact_groups:m; and DELETE /v1/accounts/:id/api-keys/:keyId/lists/:mode needs api_keys:m.

The resources a key's permissions object can name:

ResourceMeaning
messagesSend and read SMS messages
phone_numbersProvision, search and release phone numbers
contactsManage contacts
contact_groupsManage contact groups and broadcasts
listsManage the allow and deny contact lists
complianceCASL consents, opt-outs and DNCL checks
webhooksManage webhook endpoints
verifySend OTPs and check verifications
accountView or update the account profile and usage
api_keysManage API keys
emailsSend and read email
email_domainsManage email domains
email_suppressionsManage email suppression lists
email_templatesManage stored email templates

A new key defaults to the permissions of the key that created it: omit permissions entirely and it gets exactly what the caller has. Asking for more takes a password step-up (an X-Step-Up-Token header with the api_keys:elevate scope), or the request is refused with 403 PERMISSION_ESCALATION. A test key can only create test keys.

Rate limits

Each account can make 100 API requests per second, across all of its keys, test and live alike. Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). A request over the limit is refused with 429 RATE_LIMITED and Retry-After: 1, and nothing is charged for it.

Sending has its own limits on top of this, such as a daily cap and a per-recipient rate: see SMS sending limits and email limits.

Webhooks

Register a webhook endpoint with the events you want to receive. The signing_secret in the response is shown once, at creation.

bash
curl -X POST https://api.honkio.ca/v1/webhooks \
  -H "Authorization: Bearer mk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/honkio",
    "events": ["message.delivered", "message.failed", "message.received"]
  }'

# Response 201: { "id": "...", "url": "...", "events": [...],
#   "signing_secret": "64 hex characters, shown once", "active": true, "created_at": "..." }

SMS, opt-out and phone number events and their payloads are listed on the SMS API page.

Email events (email.* and email_domain.*) and their payloads are listed on the email API page.

The envelope

Every event arrives as one JSON object with the same six top level fields. Only data changes from event to event.

FieldMeaning
idThe event id, a UUID. It is the same on every attempt and every replay of this event, so store it and skip any id you have already processed.
typeThe event name, e.g. message.received. The same value is sent in the X-HonkIO-Event header.
createdWhen the event happened, ISO 8601 in UTC with milliseconds. A retry or a replay keeps the original value.
account_idThe HonkIO account the event belongs to.
livemodetrue when a live key's action caused the event, false for a test key's simulation. Account and phone number events are always true.
dataThe event's own fields, listed under each event below.

Every event also carries a top-level livemode field: true when a live key's action caused it, false for a test key's simulation. Account and phone number events are always live.

Retries, dead letters and disabling

Answer with a 2xx within 10 seconds, directly: deliveries never follow a redirect, so a 3xx counts as a failed attempt, as does any other status, a timeout or a connection error. A failed event is retried once about a second later (skipped when the endpoint was already failing), then about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 16 hours apart: about 24 hours in all, up to eight attempts. Delivery is at least once from the first attempt: once an attempt has failed, the event is stored and its retries survive our restarts and deploys (a crash during the very first attempt can lose that one event). Every attempt is signed afresh and carries the same event id, so deduplicate on it. An event that fails every attempt goes to your dead-letter queue. Your endpoint stays on through all of this: it is disabled only once every attempt to it has failed for at least 24 hours, with no successful delivery in between, and at least 5 different events have failed; any successful delivery starts that over, and so does a failure more than 17 hours after the last one (the longest gap between retries, plus an hour). We email you an hour into a failure streak and again if the endpoint is disabled, and while it is failing GET /v1/webhooks/:id shows failing_since, failed_events and last_failure_reason. Events that failed before an endpoint was disabled are kept as dead letters you can replay; events raised while it is disabled are not delivered to it and are not stored. List dead letters with GET /v1/webhooks/:id/dead-letters, send one again with POST /v1/webhooks/dead-letters/:id/replay (409 DEAD_LETTER_ALREADY_REPLAYED if it already went or a replay is in progress) or drop it with DELETE /v1/webhooks/dead-letters/:id, and re-enable the endpoint with POST /v1/webhooks/:id/reactivate. Replayed dead letters are kept for 90 days; every dead letter goes at the message-retention cutoff.

Verifying signatures

Every delivery carries X-HonkIO-Signature, X-HonkIO-Timestamp and X-HonkIO-Event. Verify the signature before you trust the payload, and reject anything that does not match. The signing secret is returned once, when you create the webhook.

bash
signed_string = timestamp + "." + body
signature     = HMAC_SHA256(key = signing_secret, message = signed_string)

# timestamp: the X-HonkIO-Timestamp value as sent, decimal Unix epoch seconds
# body:      the raw request bytes, read before any JSON parsing
# key:       the UTF-8 bytes of the secret as issued, not hex decoded
  • Separator: a literal period sits between the timestamp and the first byte of the body.
  • Timestamp: the X-HonkIO-Timestamp value exactly as sent, in decimal Unix epoch seconds.
  • Body: the raw request bytes, read before any JSON parsing. Anything that parses and re-serializes the JSON first will change the bytes, and the digest will never match.
  • Signature: lowercase hex, 64 characters. Reject anything that is not 64 hex characters before you decode it, then compare in constant time.
  • Secret: the UTF-8 bytes of the signing secret exactly as issued. Do not decode it first.
⚠️ The signing secret is 32 random bytes rendered as 64 hex characters, so it looks like something you should decode back to 32 bytes. It is not. The HMAC key is the 64 character string itself, and there is no prefix to strip. Decoding it first is the most common reason a verifier never matches.

A complete verifier, failing closed, in Node:

javascript
import { createHmac, timingSafeEqual } from 'node:crypto'

// rawBody must be the unparsed body. Most frameworks hand you a parsed object
// by default; reach for the raw buffer (express.raw, request.text(), and so on).
export function verifyHonkioWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
  const timestamp = headers['x-honkio-timestamp']
  const signature = headers['x-honkio-signature']
  if (!timestamp || !signature) return false

  const ts = Number.parseInt(timestamp, 10)
  if (!Number.isFinite(ts)) return false
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false

  // Buffer.from(x, 'hex') drops invalid bytes without complaining, which can
  // truncate two different values into a match. Check the shape first.
  if (!/^[0-9a-f]{64}$/i.test(signature)) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature, 'hex'))
}

Check your implementation against this vector. If you reproduce the signature, you are done:

bash
secret     deadbeef00112233445566778899aabbccddeeffdeadbeef0123456789abcdef
timestamp  1788920816
body       {"id":"00000000-0000-4000-8000-000000000000","type":"message.delivered","created":"2026-09-09T02:26:57Z","account_id":"acc_test","data":{"message_id":"msg_test","to":"+16135550123","status":"delivered"}}

signature  058aca981d55947bb71d2ba4ff98487d5f5c6caf01189af72266cb3f1e22ccbf

The secret above is a throwaway that belongs to no account, so you can commit it to a test. Your own secret never needs to leave your server, and we will not send it, or any signature, by email.

Reject anything outside a 300 second window against X-HonkIO-Timestamp, and deduplicate on the event id. Delivery is at least once, so the same id can legitimately arrive more than once.

Retries and replays are signed fresh at the moment they go out, so one event id can arrive with a different timestamp and a different signature each time. Replayed deliveries also carry X-HonkIO-Replay: true. Always verify against the timestamp header that arrived with that request, never one you stored earlier.

There is no secret rotation endpoint. To change a secret, register a second endpoint, confirm it verifies, then delete the first. Both fire while both are active, which your event id deduplication absorbs.

Account events

Three account-level events carry no message: account.delivery_warning when more than 10% of your last 50 live messages failed at the carrier; account.sending_paused when an automatic pause trips (channel is sms or email; an email pause adds bounce_rate_pct or complaint_rate_pct); account.spend_warning when an hour's live spend exceeds the larger of $5 and half a typical day. Each is also emailed. Their payloads are in the reference below.

account.delivery_warning

More than 10% of your last 50 live messages failed at the carrier. Sent before any automatic pause, at most once per pause window. Also emailed.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "account.delivery_warning",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "failure_rate_pct": 14,
    "failed": 7,
    "sample": 50
  }
}
data fieldMeaning
failure_rate_pctThe share of the sample that failed, in percent, rounded.
failedHow many messages in the sample failed at the carrier.
sampleHow many of your most recent live messages were looked at.

account.sending_paused

An automatic pause stopped live sending until paused_until. Test mode is not affected. Also emailed.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "account.sending_paused",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "channel": "sms",
    "paused_until": "2026-09-25T14:00:00.000Z",
    "reason": "FAILURE_RATE"
  }
}
data fieldMeaning
channelWhich sending stopped: sms or email. The other channel is not paused.
paused_untilWhen live sending resumes on its own, ISO 8601 in UTC.
reasonWhy: OPT_OUT_RATE or FAILURE_RATE for SMS; BOUNCE_RATE or COMPLAINT_RATE for email.
bounce_rate_pctEmail pauses for BOUNCE_RATE only: your recent live bounce rate, in percent.
complaint_rate_pctEmail pauses for COMPLAINT_RATE only: your recent live complaint rate, in percent.

account.spend_warning

Your live spend in the last hour exceeded the larger of $5 and half a typical day. Also emailed.

json
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "account.spend_warning",
  "created": "2026-09-24T12:00:00.000Z",
  "account_id": "clxxxaccountxxxxxxxxxxxxx",
  "livemode": true,
  "data": {
    "spent_last_hour_cents": 1250,
    "typical_daily_cents": 400,
    "threshold_cents": 500
  }
}
data fieldMeaning
spent_last_hour_centsYour live spend in the last hour, in Canadian cents.
typical_daily_centsWhat your account usually spends in a whole day, in Canadian cents; 0 with no history yet.
threshold_centsThe spend in an hour that triggers this warning for your account, in Canadian cents.

Node.js SDK

@honkio/node is the official Node.js SDK: Node 18 or newer, ESM and CommonJS, no runtime dependencies. Every call resolves to { data, error } and API errors never throw: error.name is the API code (NON_CANADIAN_NUMBER, for example) and error.statusCode its HTTP status.

bash
npm install @honkio/node
javascript
import { Honkio } from '@honkio/node'

const honkio = new Honkio(process.env.HONKIO_API_KEY)

const { data, error } = await honkio.messages.send({
  from: '+1416XXXXXXX', // one of your HonkIO numbers
  to: '+1613XXXXXXX',   // a Canadian number you hold consent for
  body: 'Hello from HonkIO!',
})
if (error) console.error(error.name, error.message)
else console.log(data.id, data.status)

It covers messages, consents and webhooks, including webhooks.verify, which checks a delivery’s signature for you, and every email resource: emails, batch sends, domains, suppressions and templates.

Full SDK reference on npm →

AI agents (MCP)

HonkIO ships a Model Context Protocol server, so an AI coding agent can send SMS, run phone verification, buy Canadian numbers and check CASL consent on your behalf, in plain language and with no SDK to wire up. It talks to this same REST API with your API key, from our hosted endpoint or from a copy on your machine.

The hosted endpoint needs nothing installed. Point your client at it and send your key in a header. In Claude Code:

bash
claude mcp add --transport http honkio https://mcp.honkio.ca/mcp \
  --header "Authorization: Bearer mk_test_YOUR_KEY"

Or in a shared .mcp.json, reading the key from your shell so it never lands in git. Cursor, VS Code, claude.ai connectors and the Claude Messages API connect the same way, by URL.

json
{
  "mcpServers": {
    "honkio": {
      "type": "http",
      "url": "https://mcp.honkio.ca/mcp",
      "headers": { "Authorization": "Bearer ${HONKIO_API_KEY}" }
    }
  }
}

VS Code asks for your key the first time the server starts and keeps it. Cursor installs with a placeholder: replace mk_test_YOUR_KEY in ~/.cursor/mcp.json before use.

You can also sign in instead of pasting a key. In Claude Code, add the endpoint without a header and run /mcp: a page on honkio.ca asks you to approve the connection and choose live or test mode. A live connection also asks for your account password. Clients that support OAuth, such as claude.ai connectors, do the same on their own. Each connection appears on your API keys page and can be revoked there.

Prefer to run it locally? The same server is on npm. Nothing to install: npx fetches it on first use.

json
{
  "mcpServers": {
    "honkio": {
      "command": "npx",
      "args": ["-y", "@honkio/mcp"],
      "env": { "HONKIO_API_KEY": "mk_test_YOUR_KEY" }
    }
  }
}

Use a test key while you explore: it works before your first top-up, and sends and verifications are simulated for free, nothing is sent or charged. Tools that buy or release phone numbers, change webhooks, or run erasure need a live key.

62 tools are available, covering the list below plus email. The 15 email tools appear only when email is enabled on your account.

  • Sending SMS, listing messages and reading delivery status
  • Starting a verification code, checking it and listing attempts
  • Searching, buying and releasing Canadian numbers
  • Recording and checking CASL consent, and handling opt-outs
  • Managing webhooks, and replaying failed deliveries
  • Account details, usage and API key management
  • Checking usage against the daily cap, requesting a higher volume and seeing the top-up allowance
  • Email: sending, scheduled sends, domains, suppressions and templates (email accounts only)

Then just ask

"Find an available 416 number, tell me what it costs, and don't buy anything yet."

On a live key, buying a number and sending messages spend real money. Agents act on instructions that can be vaguer than you intended, so explore with a test key first.
Full tool reference on npm →

Error codes

Every error is a flat JSON object: code, message (follows Accept-Language), messageEn, messageFr, statusCode, and an optional details field with more about what failed. A malformed request, such as a missing field or a value outside its documented bounds, returns 422 VALIDATION_ERROR with details naming the field. A request the framework itself rejects before your code runs, such as invalid JSON (400), a body over the size limit (413) or an unsupported content type (415), returns a Fastify FST_ERR_* code with an English-only message instead.

HTTPCodeMeaning
401UNAUTHORIZEDMissing or invalid API key
401API_KEY_REVOKEDThis API key has been revoked. Use a different key.
402INSUFFICIENT_BALANCEAccount balance too low
402PAYMENT_REQUIREDThe account has not completed its first top-up. Live keys are blocked until then except a few account and pricing reads; test keys are unaffected.
403FORBIDDENThe API key lacks the permission this action needs, skip_consent_check was sent with a live key, the account is suspended or closed (see details.reason), or the id in the path belongs to another account
403LIVE_KEY_REQUIREDA test key was used for something only a live key can do.
403PERMISSION_ESCALATIONA new or rotated key asked for permissions broader than the caller's own, without a password step-up.
403KEY_FENCEDThis key is restricted by its own allow or deny lists or by default-deny, so it cannot change those lists or the groups and contacts they include, change any key’s list settings, create keys, or rotate any key but itself.
403ACCOUNT_NOT_VERIFIEDThe account has not verified an owner phone number yet, required before a live send.
403SENDING_PAUSEDLive sending is paused on this account (details show when it resumes and why)
404NOT_FOUNDNo resource matches the given id.
409DEAD_LETTER_ALREADY_REPLAYEDThis event was already replayed, or a replay of it is in progress. Nothing was sent, and retrying won't help.
409CONFLICTA resource with that identifier already exists, or a key rotation failed because the key was already revoked, is a session key, or another request rotated it first
422VALIDATION_ERRORRequest body or query failed validation (see details)
422WEBHOOK_LIMIT_REACHEDReached the limit of 10 webhook endpoints per account. Delete one before registering another
429RATE_LIMITEDToo many requests. Slow down
500INTERNAL_ERRORAn unexpected server error occurred. Retry
502WEBHOOK_REPLAY_FAILEDThe endpoint did not accept the replay. The event stays in the dead-letter queue
503SERVICE_UNAVAILABLENew registrations are temporarily disabled (POST /v1/accounts), or phone verification is temporarily unavailable (POST /v1/accounts/:id/phone-verification). Retry later

Codes that belong to one product are on its own page: SMS error codes and email error codes.

Platform Docs: Authentication, Webhooks, SDK and MCP | HonkIO