Sections
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.
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:
| Resource | Meaning |
|---|---|
| messages | Send and read SMS messages |
| phone_numbers | Provision, search and release phone numbers |
| contacts | Manage contacts |
| contact_groups | Manage contact groups and broadcasts |
| lists | Manage the allow and deny contact lists |
| compliance | CASL consents, opt-outs and DNCL checks |
| webhooks | Manage webhook endpoints |
| verify | Send OTPs and check verifications |
| account | View or update the account profile and usage |
| api_keys | Manage API keys |
| emails | Send and read email |
| email_domains | Manage email domains |
| email_suppressions | Manage email suppression lists |
| email_templates | Manage 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.
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.
| Field | Meaning |
|---|---|
| id | The 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. |
| type | The event name, e.g. message.received. The same value is sent in the X-HonkIO-Event header. |
| created | When the event happened, ISO 8601 in UTC with milliseconds. A retry or a replay keeps the original value. |
| account_id | The HonkIO account the event belongs to. |
| livemode | true when a live key's action caused the event, false for a test key's simulation. Account and phone number events are always true. |
| data | The 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.
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-Timestampvalue 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.
A complete verifier, failing closed, in Node:
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:
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 058aca981d55947bb71d2ba4ff98487d5f5c6caf01189af72266cb3f1e22ccbfThe 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.
{
"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 field | Meaning |
|---|---|
| failure_rate_pct | The share of the sample that failed, in percent, rounded. |
| failed | How many messages in the sample failed at the carrier. |
| sample | How 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.
{
"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 field | Meaning |
|---|---|
| channel | Which sending stopped: sms or email. The other channel is not paused. |
| paused_until | When live sending resumes on its own, ISO 8601 in UTC. |
| reason | Why: OPT_OUT_RATE or FAILURE_RATE for SMS; BOUNCE_RATE or COMPLAINT_RATE for email. |
| bounce_rate_pct | Email pauses for BOUNCE_RATE only: your recent live bounce rate, in percent. |
| complaint_rate_pct | Email 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.
{
"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 field | Meaning |
|---|---|
| spent_last_hour_cents | Your live spend in the last hour, in Canadian cents. |
| typical_daily_cents | What your account usually spends in a whole day, in Canadian cents; 0 with no history yet. |
| threshold_cents | The 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.
npm install @honkio/nodeimport { 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:
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.
{
"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.
{
"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."
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.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | Missing or invalid API key |
| 401 | API_KEY_REVOKED | This API key has been revoked. Use a different key. |
| 402 | INSUFFICIENT_BALANCE | Account balance too low |
| 402 | PAYMENT_REQUIRED | The 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. |
| 403 | FORBIDDEN | The 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 |
| 403 | LIVE_KEY_REQUIRED | A test key was used for something only a live key can do. |
| 403 | PERMISSION_ESCALATION | A new or rotated key asked for permissions broader than the caller's own, without a password step-up. |
| 403 | KEY_FENCED | This 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. |
| 403 | ACCOUNT_NOT_VERIFIED | The account has not verified an owner phone number yet, required before a live send. |
| 403 | SENDING_PAUSED | Live sending is paused on this account (details show when it resumes and why) |
| 404 | NOT_FOUND | No resource matches the given id. |
| 409 | DEAD_LETTER_ALREADY_REPLAYED | This event was already replayed, or a replay of it is in progress. Nothing was sent, and retrying won't help. |
| 409 | CONFLICT | A 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 |
| 422 | VALIDATION_ERROR | Request body or query failed validation (see details) |
| 422 | WEBHOOK_LIMIT_REACHED | Reached the limit of 10 webhook endpoints per account. Delete one before registering another |
| 429 | RATE_LIMITED | Too many requests. Slow down |
| 500 | INTERNAL_ERROR | An unexpected server error occurred. Retry |
| 502 | WEBHOOK_REPLAY_FAILED | The endpoint did not accept the replay. The event stays in the dead-letter queue |
| 503 | SERVICE_UNAVAILABLE | New 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.
HonkIO