npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@sendoka/node

v0.2.0

Published

Official Node.js / TypeScript SDK for the Sendoka email + SMS API.

Readme

@sendoka/node

Official Node.js / TypeScript SDK for the Sendoka email + SMS API.

Not published to npm yet. From a checkout of the repository, build and pack it, then install the tarball in your project:

cd packages/sdk-node && npm install && npm run build && npm pack
npm install /path/to/packages/sdk-node/sendoka-node-0.2.0.tgz
import { Sendoka } from "@sendoka/node";

const sendoka = new Sendoka(); // reads SENDOKA_API_KEY

await sendoka.emails.send({
  from: "[email protected]",
  to: ["[email protected]"],
  subject: "Hello",
  html: "<p>Hello</p>",
});

Node 20+. Zero dependencies — fetch and node:crypto are all it uses.

Why this is hand-written

The generated shape of this API is a flat bag of postV1EmailsBatch functions. The parts callers actually get wrong are the parts a generator has nothing to say about:

Retries reuse one idempotency key. A key minted per attempt is worse than no key at all — every retry looks like a fresh request and delivers a duplicate, which is the exact failure the retry was meant to prevent. One key is minted per logical call and reused across the whole sequence, on every endpoint that honours Idempotency-Key: emails.send, emails.sendBatch, sms.send, sms.sendBatch, audiences.send, verifications.create, and POST /v1/phone-numbers, /v1/brands, /v1/campaigns through client.post.

A write without a key is not resent through an ambiguous failure. A timeout, a dropped connection, a 408 or a 5xx does not say whether the server acted — the first attempt may already have sent the message. The SDK retries through one only when a replay cannot act twice: a GET, or a write on one of the endpoints above. Any other POST, PATCH or DELETE is retried only on a 429, because the rate limiter refuses a request before anything runs. When one of those throws, find out what happened before sending it again.

409 is not retryable. It means an idempotency key is in flight, or the body changed under one. Hammering it makes both worse. Only 408, 429 and 5xx are retried, and a server-sent Retry-After always wins over the exponential backoff — up to 60 seconds. A longer Retry-After is an hourly or daily ceiling, not a rate: the error is thrown straight away with retryAfter set, rather than the call blocking for an hour. A 429 that is a quota rather than a rate (USAGE_LIMIT_EXCEEDED, TENANT_QUOTA_EXCEEDED, PLAN_RESOURCE_LIMIT, WARMUP_LIMIT_EXCEEDED, PROVIDER_QUOTA_EXCEEDED, SANDBOX_DAILY_LIMIT, and for test keys TEST_SCHEDULE_LIMIT_EXCEEDED) is not retried at all: no backoff moves a monthly or daily ceiling.

The key comes back on the error. err.idempotencyKey (on SendokaError and SendokaConnectionError) is the key the request carried, including one the SDK minted. Make the same call again with { idempotencyKey: err.idempotencyKey } and the server replays what the first attempt did instead of doing it twice. For retries that outlive one call — a queue redelivery, a rerun job — derive your own key from something stable about that one call, such as an order id, and pass it every time. Never key a verification by user or destination: a replay answers with the first code's verification, expired or not, and sends nothing.

Paging stops on the cursor, not just the flag. paginate() returns when either has_more is false or next_cursor is null. Trusting the flag alone is how hand-rolled paging becomes an infinite loop.

Errors

import { SendokaError, SendokaConnectionError } from "@sendoka/node";

try {
  await sendoka.emails.send({ ... });
} catch (err) {
  if (err instanceof SendokaError) {
    err.status;    // 422
    err.code;      // "SUPPRESSED"  ← branch on this
    err.type;      // "validation_error"
    err.retryable; // false
  }
}

Branch on code. type is a coarse family and message is prose that may be reworded.

Pagination

for await (const message of sendoka.emails.all({ status: "bounced" })) {
  console.log(message.id);
}

Every list method has an all() streaming variant alongside the single-page list().

Webhooks

import { verifyWebhookSignature } from "@sendoka/node";

// Express — note express.raw, not express.json
app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyWebhookSignature({
    payload: req.body.toString("utf8"),
    signature: req.header("X-Sendoka-Signature-V2")!,
    timestamp: req.header("X-Sendoka-Timestamp")!,
    secret: process.env.SENDOKA_WEBHOOK_SECRET!,
  });
  if (!ok) return res.status(400).end();
  res.status(200).end();
});

payload must be the raw body as received. JSON.stringify of a parsed object is a different string — key order and number formatting both move — and every check will fail in a way that looks like a wrong secret. In Next.js, call await req.text() before req.json().

Verifies X-Sendoka-Signature-V2 (HMAC over ${timestamp}.${body}), rejects deliveries more than 5 minutes from your clock in either direction, and accepts any candidate in a comma-separated list — that list is normal during a secret rotation.

Verify (OTP)

const v = await sendoka.verifications.create({ channel: "sms", to: "+14155550142" });
const result = await sendoka.verifications.check(v.id, "123456");
result.status; // "approved" | "denied"

create is idempotent: a retry under the same key replays the recorded verification instead of texting the user a second code. Use one key per code request, never one per user: for 24h a replay returns the first verification — even once its code has expired — and sends nothing, so a per-user key turns "resend code" into a silent no-op. Let the SDK mint the key, or pass a fresh one each time the user asks for a code.

A 503 on check means the attempt could not be recorded — the user's code is still good and must not be shown as wrong. It is not retried for you: check takes no idempotency key, and a resent check whose first attempt did land would spend a second attempt, or answer not_found for the code the first one approved. Ask the user to submit the code again.

Audience sends

const { job_id } = await sendoka.audiences.send(
  "aud_...",
  { channel: "email", from: "[email protected]", template: "weekly" },
  { idempotencyKey: "weekly-2026-w39" } // one key per blast you mean to send
);

A retry under the same key replays the recorded job_id instead of scheduling the list again. A 10,000-recipient blast can take longer than the default 30s timeout; the SDK's retry then gets 409 IDEMPOTENCY_IN_FLIGHT while the first request is still scheduling, and throws. The blast is running — call again later with the same key (err.idempotencyKey) to get its job_id, or give the client that sends blasts a longer timeout. 500 AUDIENCE_SEND_INCOMPLETE means the job exists but scheduling stopped part-way: it is not retryable, because a new key would schedule the list again over the rows that did land. Check GET /v1/jobs/{job_id} first.

Test mode

A sok_test_* key exercises every path without contacting a provider or sending anything real. Verifications work fully in test mode, which is what makes an OTP flow testable in CI without a handset.