New:Microsoft Teams Notifications Are Now Available in Socket.Learn more →
Get Started

@honkio/node

Package Overview
Dependencies
Maintainers
1
Versions
2
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@honkio/node

Official Node.js SDK for HonkIO: Canadian SMS and email, CASL built in.

latest
Source
npmnpm
Version
0.3.0
Version published
Maintainers
1
Created
Source

@honkio/node

The official Node.js SDK for HonkIO: Canadian SMS and email, with CASL built in. Data is stored in Canada (ca-central-1).

  • Node 18 or newer, no runtime dependencies
  • ESM and CommonJS
  • Every call resolves to { data, error }; API errors never throw
  • Live sending and live receiving (buying a number, the managed inbound email address, a domain's receiving toggle) both need the account owner's phone verified once; test keys skip it

Install

npm install @honkio/node

Quickstart

Create a key in the dashboard under API Keys → Create key (https://honkio.ca/dashboard/keys); 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. Options: new Honkio(key, { baseUrl, fetch }).

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? }:

nameWhen
an API code such as NON_CANADIAN_NUMBERthe API refused the request; statusCode is its HTTP status
network_errorno response arrived (DNS, refused connection, 30 s timeout); statusCode is null
application_errorthe response body was not JSON (a proxy error page, for example)
invalid_argumentan id was empty, . or .., or a consent call named both or neither subject; nothing was sent
invalid_signaturewebhooks.verify (or verifyWebhook) could not verify a delivery; see Webhooks

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: { attempts_remaining } on VERIFICATION_INVALID_CODE, { status: 'failed', http_status, error_reason } on WEBHOOK_REPLAY_FAILED.

The general codes (authentication, permissions, balance, validation, rate limits) are listed at https://honkio.ca/docs/platform#errors, every SMS code at https://honkio.ca/docs/sms#errors, and every email code at https://honkio.ca/docs/email#errors.

Request casing

Write request fields in camelCase (replyTo, scheduledAt, isCommercial, dnclExemptions, skipConsentCheck); they are sent as the API's snake_case. The keys inside variables, headers and metadata are yours and are sent exactly as written.

Resources

ResourceMethods
messages (SMS)send({ from, to, body }, { idempotencyKey }), get(id), list(query)
phoneNumbersareaCodes(), search({ areaCodes, limit }), provision({ phoneNumber }) (live keys; charges your balance), list(), get(id), release(id)
verifystart({ to, from, appName, codeLength, ttlMinutes }), check(id, { code }), get(id), list({ status, limit, offset })
consentscreate, list, check({ phoneNumber } or { emailAddress }), revoke(...)
webhookscreate, list, get, update, remove, verify(rawBody, headers, secret), deliveries(id, { limit }), deadLetters(id, { limit, includeReplayed }), replay(deadLetterId), discard(deadLetterId), reactivate(id), rotateSecret(id)
emailssend(body, { idempotencyKey }), get(id), list({ status, tag, to, from, since, until, domainId, limit, cursor }) (lists only the key's own mode; to is a full address in the to list, not cc or bcc, and to/from match exactly whatever their case), update(id, { scheduledAt }), cancel(id)
emails.receivedlist({ to, from, since, until, domainId, status, limit, cursor }), address(), setAddressEnabled(enabled), get(id, { htmlFormat }), raw(id), attachment(id, attachmentId) (both resolve data to a Blob), simulate(body) (test keys only); see Receiving email
batchsend([...up to 100 emails]) or send({ template, recipients: [...up to 500] })
domainscreate({ domain }), get(id), list(), update(id, { openTracking, clickTracking, receiving }), verify(id), remove(id)
suppressionscreate({ emailAddress }), list({ reason }), remove(emailAddress). Bounces, complaints and manual entries block every email; unsubscribes block commercial email only
templatescreate, get, list, update, publish, rollback(idOrAlias, { version }), versions, remove

Phone numbers

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 });

search takes active Canadian area codes (areaCodes() lists them by province) or a toll-free prefix (833, 844, 855, 866, 877, 888); 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; a balance that cannot cover them is INSUFFICIENT_BALANCE and nothing is charged or ordered. Buying a live number needs the account owner's phone verified (ACCOUNT_NOT_VERIFIED otherwise). The API does not read an Idempotency-Key on this route, so provision takes none: purchases on an 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: on CONFLICT it tells you whether the number is yours. release(id) gives the number back; the activation fee is not refunded.

Receiving SMS

Inbound texts to a provisioned number are billed per part on arrival, independent of any SDK call: messages.list({ direction: 'INBOUND' }) and messages.get(id) read them back like any other message. Each account can receive at most 1,000 texts per rolling 24 hours by default; texts past the cap are recorded without a body, are not charged, and do not fire message.received (STOP, START and HELP replies are always processed). See honkio.ca/docs/sms#limits.

Verify a phone number

const { data: verification } = await honkio.verify.start({
  from: '+1416XXXXXXX', // one of your HonkIO numbers
  to: '+1613XXXXXXX',
  appName: 'Acme',     // "Your Acme verification code is: 123456"
});

// Later, with the code the person typed:
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'
}

A verification costs the per-part message rate plus a verification upcharge (verification_upcharge_cents on GET /v1/pricing); one that the carrier refuses is refunded. Codes are 6 digits by default (codeLength: 4 | 6 | 8) and valid for 10 minutes (ttlMinutes, 1 to 60). Five wrong codes end the verification with VERIFICATION_MAX_ATTEMPTS, and an expired one answers VERIFICATION_EXPIRED: start a new one. With a test key nothing is sent and the code is all zeros (000000 at the default length).

start takes no idempotency key. One start per recipient per 60 seconds: a retry inside that window answers RATE_LIMITED, and one after it sends and bills a second code. After a network_error, look for the verification with verify.list({ status: 'pending' }) (match phone_number) before starting again.

Email

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>',
});

Marketing email must set isCommercial: true (the API's is_commercial: true). It defaults to false, and transactional email also reaches addresses that unsubscribed, so a promotion sent without the flag would get around the unsubscribe and CASL consent.

Email is transactional by default. Set isCommercial: true only for marketing: it needs CASL consent on file for the recipient (consents.create({ emailAddress, consentType: 'express', sourceDescription })), goes to exactly one recipient, and carries an unsubscribe footer and one-click unsubscribe.

Receiving email

const { data: address } = await honkio.emails.received.address();
console.log(address.example); // anything@<your-slug>.inbound.honkio.ca, minted on first call

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);

Live receiving, the managed inbound address and a domain's receiving toggle, needs the account owner's phone verified, the same gate live sending uses; test mode skips it. Each account can also receive at most 1,000 emails per rolling 24 hours by default. Mail over the cap is stored the same way as a virus rejection (status: 'rejected', reject_reason: 'daily_cap', headers only, not charged, no email.received), and the account gets account.inbound_email_capped once per 24-hour window rather than on every excess message.

Every account gets a managed inbound address for free (address(), enabled in the response). Turn it off with setAddressEnabled(false): the switch is account-wide, turns your live address off, and needs a live key (a test key gets LIVE_KEY_REQUIRED; address() on a test key still reports enabled). Mail sent to it while off is discarded, uncharged, and a simulate() to a disabled test address answers INBOUND_ADDRESS_DISABLED. setAddressEnabled(true) turns it back on. A domain verified for sending can also receive its own mail: domains.update(id, { receiving: true }) answers with receiving_status: 'pending', the inbound MX in records (purpose 'inbound') and receiving_missing if it is not published yet; publish it on the domain, then domains.verify(id) moves receiving_status to 'verified' (or lists it in receiving_missing). receiving_warning: 'apex_mx' flags a bare registrable domain, where the MX takes over all its mail; a subdomain is recommended. Receiving stays on only while the domain stays verified for sending; update(id, { receiving: false }) turns it off. list and get return the parsed message: headers, text/html (html_format: 'cid' | 'links' | 'sanitized', default links, rewrites cid: image references to the attachment route; sanitized also strips unsafe markup and parks remote images, counted in remote_images), verdicts (spf, dkim, dmarc, spam, virus), and attachment metadata.

raw(id) and attachment(id, attachmentId) download bytes, not JSON: both resolve data to a Blob (or null alongside error), so await result.data.arrayBuffer() or pipe it to a file. Both answer ATTACHMENT_EXPIRED (error.name, HTTP 410) once the 40-day retention window has passed; the message's own body (subject, text, html and headers) is separately purged 90 days after receipt, at which point email.body_purged is true.

simulate(body) fabricates a received email on a test key, useful for exercising your integration without a real sender:

await honkio.emails.received.simulate({
  from: 'ada@example.com',
  to: ['anything@your-slug.test-inbound.honkio.ca'],
  subject: 'Test',
  text: 'Hello',
});

Webhooks

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();
});

Pass the raw body, not parsed JSON. verify never throws: a parsed body, a missing secret or missing headers answer invalid_argument, and a bad or stale signature answers invalid_signature. Deliveries older than 300 seconds are refused; change it with { toleranceSeconds }.

The signature is HMAC-SHA256, hex, over `${timestamp}.${rawBody}` with the signing secret's UTF-8 bytes as the key (do not hex-decode it), sent in X-HonkIO-Signature with X-HonkIO-Timestamp and X-HonkIO-Event. A verified event is the envelope { id, type, created, account_id, livemode, data }; what data holds for each event, with examples: https://honkio.ca/docs/sms#webhook-events (SMS, opt-outs and numbers), https://honkio.ca/docs/platform#account-events (account) and https://honkio.ca/docs/email#webhooks (email). In opt_out.* events, data.phone_number is the subscriber who texted the keyword and data.from_number is your HonkIO number that received it; they fire only on keyword replies, not for opt-outs you record through the API.

The package exports the full list of subscribable types as WEBHOOK_EVENTS (and the WebhookEventType union) for create's and update's events argument. It includes message and email delivery events, opt-outs, phone-number lifecycle, and account-level warnings (account.delivery_warning, account.sending_paused, account.spend_warning, account.inbound_sms_capped, account.inbound_email_capped).

Answer 2xx within 10 seconds. A failed delivery is retried about 1 second later (unless the endpoint was already failing), then about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 16 hours apart, each attempt signed afresh with the same event.id, so deduplicate on it. Delivery is at least once from the first attempt: a failed event is stored and survives restarts, but a crash during the very first attempt can lose it. An event that fails every attempt is kept as a dead letter you can replay. The endpoint is disabled only after every attempt to it has failed for 24 hours across at least 5 events, with no success in between; events raised while it is disabled are neither delivered nor stored.

webhooks.deliveries(id) lists recent attempts (success, http_status, error_reason, duration_ms). Events that failed every attempt are dead letters: deadLetters(id) lists them, replay(deadLetterId) sends one again (a rejection is WEBHOOK_REPLAY_FAILED with http_status and error_reason in error.details, and the event stays replayable), and discard(deadLetterId) drops it. An endpoint the platform disabled comes back with reactivate(id); events raised while it was off were not stored, and the dead letters from before are not resent, so replay them. deadLetters, replay, discard and reactivate need a live key.

webhooks.rotateSecret(id) replaces the signing secret and returns the new one once. The old secret stops signing immediately. Keep answering a bad signature with a non-2xx: a delivery your endpoint rejects during the switch is retried, signed with the new secret.

Migrating from Resend

Most code moves by changing the import, the key and the client:

// import { Resend } from 'resend'; const resend = new Resend('re_...');
import { Honkio } from '@honkio/node';
const honkio = new Honkio('mk_live_...');
await honkio.emails.send({ from, to, subject, html, replyTo, attachments, tags, scheduledAt });

What differs:

  • scheduledAt takes an ISO 8601 date-time or a Date; natural language such as "in 1 hour" is refused.
  • {{{ key }}} in a template is HTML-escaped, the same as {{ key }}.
  • Commercial email follows CASL: consent on file, one recipient, unsubscribe footer.
  • Responses carry HonkIO's extra fields (status, livemode, cost_cents), and field names stay snake_case.
  • At most 50 recipients per send across to, cc and bcc.
  • Not supported: react (render to HTML first), audiences and broadcasts. Inbound email is supported (emails.received, see Receiving email); Resend has no equivalent to migrate from.

Full guide: https://honkio.ca/docs/email#migrate

License

MIT

Keywords

sms

FAQs

Package last updated on 25 Sep 2026

Related posts