
Company News
Socket Joins New OpenJS Program to Fund Node.js Security Work
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.
@honkio/node
Advanced tools
The official Node.js SDK for HonkIO: Canadian SMS and email, with CASL built in. Data is stored in Canada (ca-central-1).
{ data, error }; API errors never thrownpm install @honkio/node
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 }).
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, refused connection, 30 s timeout); statusCode is null |
application_error | the response body was not JSON (a proxy error page, for example) |
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; 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.
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.
| Resource | Methods |
|---|---|
messages (SMS) | send({ from, to, body }, { idempotencyKey }), get(id), list(query) |
phoneNumbers | areaCodes(), search({ areaCodes, limit }), provision({ phoneNumber }) (live keys; charges your balance), list(), get(id), release(id) |
tollFreeVerifications | options(), create({ phoneNumberIds, application }), update(id, { phoneNumberIds, application }), submit(id), cancel(id), get(id), list({ status }); live keys only for writes; see Toll-free numbers |
optInConfirmations | send({ from, to, brandName, language }) (live keys; billed as a message), list({ to, status, page, limit }) |
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, { limit }), deadLetters(id, { limit, includeReplayed }), replay(deadLetterId), discard(deadLetterId), reactivate(id), rotateSecret(id) |
emails | send(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.received | list({ 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 |
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). Bounces, complaints and manual entries block every email; unsubscribes block commercial email only |
templates | create, get, list, update, publish, rollback(idOrAlias, { version }), versions, remove |
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.
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.
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.
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.
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.
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',
});
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.
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 }}.status, livemode, cost_cents), and field names stay snake_case.to, cc and bcc.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
MIT
FAQs
Official Node.js SDK for HonkIO: Canadian SMS and email, CASL built in.
The npm package @honkio/node receives a total of 0 weekly downloads. As such, @honkio/node popularity was classified as not popular.
We found that @honkio/node demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Company News
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.

Security News
Two compromised GitHub Actions were re-enabled with malicious tags intact, exposing thousands of downstream repositories to Mini Shai-Hulud.

Research
/Security News
A malicious Firefox extension fetches its payload after installation to evade detection, steal Google session cookies, and automate account takeover.