@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.tgzimport { 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.
