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

@honkio/node

Package Overview
Dependencies
Maintainers
1
Versions
3
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.4.0
Version published
Weekly downloads
0
Maintainers
1
Weekly downloads
 
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) and every SMS and email code are listed at https://honkio.ca/docs/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)
tollFreeVerificationsoptions(), create({ phoneNumberIds, application }), update(id, { phoneNumberIds, application }), submit(id), cancel(id), get(id), list({ status }); live keys only for writes; see Toll-free numbers
optInConfirmationssend({ from, to, brandName, language }) (live keys; billed as a message), list({ to, status, page, limit })
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.

Toll-free numbers

A toll-free number (833, 844, 855, 866, 877, 888) sends SMS once a toll-free verification covering it is approved; until then a send from it is 403 TOLL_FREE_NOT_VERIFIED. An application covers 1 to 5 of your toll-free numbers and is paid for once, when you submit it. Live keys only.

const { data: options } = await honkio.tollFreeVerifications.options();
// options.use_cases, options.monthly_volumes, options.entity_types, options.provinces: [{ value, label_en, label_fr }]; options.fee_cents

const { data: application, error } = await honkio.tollFreeVerifications.create({
  phoneNumberIds: ['NUMBER_ID'],
  application: {
    businessName: 'Acme Clinics Inc.',
    entityType: 'PRIVATE_PROFIT',
    businessRegistrationNumber: '123456789RC0001',
    businessAddress: { line1: '100 King St W', city: 'Toronto', province: 'ON', postalCode: 'M5X 1A9' },
    contact: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@acme.ca', phone: '+1416XXXXXXX' },
    website: 'https://acme.ca',
    useCase: 'Appointments',
    useCaseSummary: 'Appointment reminders and rescheduling links for our patients.',
    sampleMessages: ['Acme Clinics: your appointment is tomorrow at 2 pm. Reply STOP to opt out.'],
    optInWorkflow: 'Patients tick an unchecked SMS box on the booking form, then confirm by replying YES.',
    optInImageUrls: ['https://acme.ca/img/booking-form-sms-box.png'],
    optInConfirmationMessage: 'Acme Clinics: you are subscribed to appointment texts. Reply STOP to opt out, HELP for help.',
    helpMessage: 'Acme Clinics: visit acme.ca/help. Reply STOP to opt out.',
    privacyPolicyUrl: 'https://acme.ca/privacy',
    monthlyVolume: '10,000',
  },
});

// DRAFT → AWAITING_PAYMENT: pay the fee at payment_url, then HonkIO staff review it.
const { data: submitted } = await honkio.tollFreeVerifications.submit(application!.id);
console.log(submitted?.payment_url);

The application is written in camelCase and returned as the API stores it, in snake_case. A bad field is VALIDATION_ERROR with the field named in error.details. After CHANGES_REQUESTED (from HonkIO staff, with reviewer_note) or REJECTED (by the carrier, with carrier_rejection_reason), fix it with update(id, { application: { ... } }) and submit(id) again, at no charge. cancel(id) withdraws a draft, an application awaiting payment, or one the carrier rejected, and frees its numbers; once the payment page has been opened it answers 409 (checkout_started), so cancel from the dashboard, which closes the open payment first. The toll_free_verification.approved, .rejected and .changes_requested webhooks tell you when it moves.

Canadian toll-free messaging also needs double opt-in: a recipient with an active consent must reply YES to a confirmation before a toll-free number may text them (451 DOUBLE_OPT_IN_REQUIRED otherwise).

const { data: confirmation, error } = await honkio.optInConfirmations.send({
  from: '+1833XXXXXXX', // your verified toll-free number
  to: '+1613XXXXXXX',
  brandName: 'Acme Clinics',
  language: 'en', // or 'fr'
});

// Later: PENDING, CONFIRMED, EXPIRED or DECLINED
const { data: confirmations } = await honkio.optInConfirmations.list({ to: '+1613XXXXXXX' });

The confirmation is billed as a normal message and stays PENDING for 7 days; sending another replaces it, at most 3 to one recipient in 24 hours (OPT_IN_CONFIRMATION_LIMIT). The recipient's YES fires consent.double_opt_in_confirmed. See honkio.ca/docs/sms/toll-free.

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/pricing-and-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/webhooks/events. 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-from-resend

License

MIT

Keywords

sms

FAQs

Package last updated on 26 Sep 2026

Related posts