Sections
API Docs
Node.js SDK
The official Node.js client for HonkIO: install, quickstart, and every resource, with examples.
Install and quickstart
Install the package, then create a client with an API key.
npm install @honkio/nodeCreate a key in the dashboard under API Keys → Create key; the full key is shown once, right after you create it. Start with a test key (mk_test_...): nothing is delivered or charged.
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) // e.g. NON_CANADIAN_NUMBER
} else {
console.log(data.id, data.status)
}new Honkio() with no argument reads HONKIO_API_KEY, and throws if neither is set.
Results and errors
data holds the API's response exactly as sent, in snake_case (scheduled_at, segment_count). On failure, error is { name, message, statusCode, details? }.
| name | When |
|---|---|
| an API code such as NON_CANADIAN_NUMBER | the API refused the request; statusCode is its HTTP status |
| network_error | no response arrived (DNS, a refused connection, a 30 second timeout); statusCode is null |
| application_error | the response body was not JSON, such as a proxy error page |
| invalid_argument | an id was empty, . or .., or a consent call named both or neither subject; nothing was sent |
| invalid_signature | webhooks.verify (or verifyWebhook) could not verify a delivery |
details is the API's details when it sent one (validation paths, retry_after, missing template keys). Otherwise it holds whatever the API sent beside the error envelope, such as attempts_remaining on VERIFICATION_INVALID_CODE.
General codes are listed on the Platform page, SMS codes on the SMS page, and email codes on the Email page.
Write request fields in camelCase (replyTo, scheduledAt, isCommercial, dnclExemptions, skipConsentCheck); they are sent as the API's snake_case. Keys inside variables, headers and metadata are yours and are sent exactly as written.
Options and keys
The second argument to new Honkio takes two options: baseUrl, which defaults to https://api.honkio.ca, and fetch, which defaults to the global fetch (Node 18 or newer). Pass your own fetch to use a polyfill or to intercept requests.
const honkio = new Honkio('mk_live_...', {
baseUrl: 'https://api.honkio.ca', // default
fetch: myFetch, // default: the global fetch
})A key's prefix is its mode: mk_test_ keys simulate everything and never reach the carrier or your balance; mk_live_ keys send for real. Nothing else about calling the SDK changes between them.
Resources
Every resource on the client, and the methods it exposes:
| Resource | Methods |
|---|---|
| messages (SMS) | send({ from, to, body }, { idempotencyKey }), get(id), list(query) |
| phoneNumbers | areaCodes(), search({ areaCodes, limit }), provision({ phoneNumber }), list(), get(id), release(id) |
| verify | start({ to, from, appName, codeLength, ttlMinutes }), check(id, { code }), get(id), list({ status, limit, offset }) |
| consents | create, list, check({ phoneNumber } or { emailAddress }), revoke(...) |
| webhooks | create, list, get, update, remove, verify(rawBody, headers, secret), deliveries(id), deadLetters(id), replay(deadLetterId), discard(deadLetterId), reactivate(id), rotateSecret(id) |
| emails | send(body, { idempotencyKey }), get(id), list(query), update(id, { scheduledAt }), cancel(id) |
| emails.received | list(query), address(), setAddressEnabled(enabled), get(id, { htmlFormat }), raw(id), attachment(id, attachmentId), simulate(body) |
| batch | send([...up to 100 emails]) or send({ template, recipients: [...up to 500] }) |
| domains | create({ domain }), get(id), list(), update(id, { openTracking, clickTracking, receiving }), verify(id), remove(id) |
| suppressions | create({ emailAddress }), list({ reason }), remove(emailAddress) |
| templates | create, get, list, update, publish, rollback(idOrAlias, { version }), versions, remove |
More resources
Short examples for the rest:
// Record consent
await honkio.consents.create({
phoneNumber: '+1613XXXXXXX',
consentType: 'express',
sourceDescription: 'Website opt-in form',
})
// Add a sending domain
await honkio.domains.create({ domain: 'mail.acme.ca' })
// List bounces and complaints
await honkio.suppressions.list({ reason: 'hard_bounce' })
// Publish a template draft
await honkio.templates.publish('shipping-update')
// One template, up to 500 recipients
await honkio.batch.send({
from: 'noreply@mail.acme.ca',
template: { id: 'shipping-update' },
recipients: [{ to: 'ada@example.com', variables: { firstName: 'Ada' } }],
})Phone numbers
search takes active Canadian area codes (areaCodes() lists them by province) or a toll-free prefix; it is limited to 30 searches a minute. provision needs a live key and charges the first month's rent plus a one-time activation fee, both shown on each search result and on GET /v1/pricing.
const { data: available } = await honkio.phoneNumbers.search({ areaCodes: ['416', '647'], limit: 5 })
// available[0]: { phone_number, region, upfront_cost_cents, activation_fee_cents, monthly_cost_cents, ... }
const { data: number, error } = await honkio.phoneNumbers.provision({
phoneNumber: available![0]!.phone_number,
})The API does not read an Idempotency-Key on this route, so provision takes none: purchases on one account run one at a time (PURCHASE_IN_PROGRESS, retry shortly), and a number you already hold answers 409 CONFLICT, so a retry after a timeout cannot buy it twice. After a network_error, call list() before retrying.
Verify a phone number
A verification costs the per part message rate plus a verification upcharge; one the carrier refuses is refunded. Codes are 6 digits by default (codeLength: 4, 6 or 8) and valid for 10 minutes (ttlMinutes, 1 to 60). Five wrong codes end it with VERIFICATION_MAX_ATTEMPTS, and an expired one answers VERIFICATION_EXPIRED. With a test key nothing is sent and the code is all zeros.
const { data: verification } = await honkio.verify.start({
from: '+1416XXXXXXX', // one of your HonkIO numbers
to: '+1613XXXXXXX',
appName: 'Acme',
})
const { data, error } = await honkio.verify.check(verification!.id, { code: '123456' })
if (error?.name === 'VERIFICATION_INVALID_CODE') {
console.log(error.details) // { attempts_remaining: 4 }
} else if (data) {
console.log(data.status) // 'verified'
}start takes no idempotency key: one start per recipient per 60 seconds, and a retry inside that window answers RATE_LIMITED. After a network_error, list pending verifications and match the phone number before starting a new one.
const { data, error } = await honkio.emails.send({
from: 'Acme <onboarding@test.honkio.ca>',
to: 'delivered@test.honkio.ca',
subject: 'Hello from HonkIO',
html: '<p>It works.</p>',
})isCommercial defaults to false: email is transactional unless you set it. Marketing email needs isCommercial: true, CASL consent on file for the recipient, reaches exactly one recipient, and carries an unsubscribe footer.
Receiving email
Every account gets a managed inbound address for free (address(), enabled in the response). A domain verified for sending can also receive its own mail once you turn it on. list and get return the parsed message: headers, text or html, verdicts, and attachment metadata.
const { data: address } = await honkio.emails.received.address()
console.log(address.example) // anything@<your-slug>.inbound.honkio.ca
const { data: page } = await honkio.emails.received.list({ limit: 10 })
const { data: email } = await honkio.emails.received.get(page!.data[0]!.id)
console.log(email.from, email.subject, email.verdicts)raw and attachment download bytes, not JSON: both resolve data to a Blob, so read it with arrayBuffer() or pipe it to a file. Both answer ATTACHMENT_EXPIRED once the 40 day retention window has passed.
simulate fabricates a received email on a test key, useful for exercising your integration without a real sender.
Webhooks
Pass the raw body, not parsed JSON, to webhooks.verify (or the standalone verifyWebhook export). It never throws: a parsed body, a missing secret or missing headers answer invalid_argument, and a bad or stale signature answers invalid_signature.
const webhookSecret = process.env.HONKIO_WEBHOOK_SECRET
if (!webhookSecret) throw new Error('Set HONKIO_WEBHOOK_SECRET')
app.post('/webhooks/honkio', express.raw({ type: 'application/json' }), (req, res) => {
const { data: event, error } = honkio.webhooks.verify(req.body, req.headers, webhookSecret)
if (error) return res.status(400).end()
if (!event.livemode) console.log('test event')
res.status(200).end()
})The signature is HMAC-SHA256, hex, over the timestamp and the raw body, sent in X-HonkIO-Signature with X-HonkIO-Timestamp and X-HonkIO-Event. Deliveries older than 300 seconds are refused by default; change it with toleranceSeconds.
A verified event is the envelope: id, type, created, account_id, livemode, data. Deduplicate on id: a failed delivery is retried for about 24 hours, each attempt signed afresh with the same event id.
deliveries(id) lists recent attempts. Events that failed every attempt are dead letters: deadLetters(id) lists them, replay(deadLetterId) sends one again, and discard(deadLetterId) drops it. reactivate(id) re-enables an endpoint the platform disabled.
Pagination
List calls follow one of three shapes. Page based (page, limit, and a meta object with page, limit, total and pages): messages.list and consents.list. Offset based (limit, offset): verify.list. Cursor based (limit, cursor, answering with data, has_more and next_cursor): emails.list, emails.received.list, suppressions.list and templates.list. Everything else, such as phoneNumbers.list and webhooks.list, returns every row in one call.
HonkIO