@sendoka/node 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @sendoka/node
2
+
3
+ Official Node.js / TypeScript SDK for the Sendoka email + SMS API.
4
+
5
+ Not published to npm yet. From a checkout of the repository, build and pack
6
+ it, then install the tarball in your project:
7
+
8
+ ```bash
9
+ cd packages/sdk-node && npm install && npm run build && npm pack
10
+ npm install /path/to/packages/sdk-node/sendoka-node-0.2.0.tgz
11
+ ```
12
+
13
+ ```ts
14
+ import { Sendoka } from "@sendoka/node";
15
+
16
+ const sendoka = new Sendoka(); // reads SENDOKA_API_KEY
17
+
18
+ await sendoka.emails.send({
19
+ from: "you@yourdomain.com",
20
+ to: ["dev@example.com"],
21
+ subject: "Hello",
22
+ html: "<p>Hello</p>",
23
+ });
24
+ ```
25
+
26
+ Node 20+. Zero dependencies — `fetch` and `node:crypto` are all it uses.
27
+
28
+ ## Why this is hand-written
29
+
30
+ 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:
31
+
32
+ **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`.
33
+
34
+ **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.
35
+
36
+ **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.
37
+
38
+ **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.
39
+
40
+ **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.
41
+
42
+ ## Errors
43
+
44
+ ```ts
45
+ import { SendokaError, SendokaConnectionError } from "@sendoka/node";
46
+
47
+ try {
48
+ await sendoka.emails.send({ ... });
49
+ } catch (err) {
50
+ if (err instanceof SendokaError) {
51
+ err.status; // 422
52
+ err.code; // "SUPPRESSED" ← branch on this
53
+ err.type; // "validation_error"
54
+ err.retryable; // false
55
+ }
56
+ }
57
+ ```
58
+
59
+ Branch on `code`. `type` is a coarse family and `message` is prose that may be reworded.
60
+
61
+ ## Pagination
62
+
63
+ ```ts
64
+ for await (const message of sendoka.emails.all({ status: "bounced" })) {
65
+ console.log(message.id);
66
+ }
67
+ ```
68
+
69
+ Every list method has an `all()` streaming variant alongside the single-page `list()`.
70
+
71
+ ## Webhooks
72
+
73
+ ```ts
74
+ import { verifyWebhookSignature } from "@sendoka/node";
75
+
76
+ // Express — note express.raw, not express.json
77
+ app.post("/webhooks", express.raw({ type: "application/json" }), (req, res) => {
78
+ const ok = verifyWebhookSignature({
79
+ payload: req.body.toString("utf8"),
80
+ signature: req.header("X-Sendoka-Signature-V2")!,
81
+ timestamp: req.header("X-Sendoka-Timestamp")!,
82
+ secret: process.env.SENDOKA_WEBHOOK_SECRET!,
83
+ });
84
+ if (!ok) return res.status(400).end();
85
+ res.status(200).end();
86
+ });
87
+ ```
88
+
89
+ **`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()`.
90
+
91
+ 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.
92
+
93
+ ## Verify (OTP)
94
+
95
+ ```ts
96
+ const v = await sendoka.verifications.create({ channel: "sms", to: "+14155550142" });
97
+ const result = await sendoka.verifications.check(v.id, "123456");
98
+ result.status; // "approved" | "denied"
99
+ ```
100
+
101
+ `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.
102
+
103
+ 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.
104
+
105
+ ## Audience sends
106
+
107
+ ```ts
108
+ const { job_id } = await sendoka.audiences.send(
109
+ "aud_...",
110
+ { channel: "email", from: "news@yourdomain.com", template: "weekly" },
111
+ { idempotencyKey: "weekly-2026-w39" } // one key per blast you mean to send
112
+ );
113
+ ```
114
+
115
+ 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.
116
+
117
+ ## Test mode
118
+
119
+ 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.
@@ -0,0 +1,61 @@
1
+ export interface SendokaOptions {
2
+ /** `sok_live_…` or `sok_test_…`. Defaults to `process.env.SENDOKA_API_KEY`. */
3
+ apiKey?: string;
4
+ baseUrl?: string;
5
+ /** Per-request timeout in ms. Default 30s. */
6
+ timeout?: number;
7
+ /**
8
+ * Retries for RETRYABLE failures only (429, 5xx, network). Default 2.
9
+ *
10
+ * Only a request that is safe to send twice is retried through an ambiguous
11
+ * failure (timeout, network error, 408, 5xx): a GET, or a write to an
12
+ * endpoint that honours `Idempotency-Key`, which gets one minted
13
+ * automatically. Any other POST / PATCH / DELETE is retried only on a 429 —
14
+ * a rate-limited request was never processed. A retry without a key is how a
15
+ * timeout becomes a duplicate message, which is the failure mode this SDK
16
+ * exists to keep callers out of.
17
+ */
18
+ maxRetries?: number;
19
+ fetch?: typeof globalThis.fetch;
20
+ /**
21
+ * Backoff before attempt N, in ms. Defaults to exponential (250ms doubling,
22
+ * capped at 8s) and always yields to a server-sent `Retry-After`.
23
+ *
24
+ * Injectable so a test suite is not forced to sit through real sleeps to
25
+ * exercise the retry path — a retry policy nobody can test cheaply is one
26
+ * nobody tests.
27
+ */
28
+ retryDelay?: (attempt: number, retryAfterSeconds?: number) => number;
29
+ }
30
+ export interface RequestOptions {
31
+ /**
32
+ * Overrides the automatic key. Pass explicitly to make a retry replay — the
33
+ * SDK's own key lives for one call, so a retry from your code (a queue
34
+ * redelivery, a rerun job) needs a key derived from something stable.
35
+ */
36
+ idempotencyKey?: string;
37
+ signal?: AbortSignal;
38
+ }
39
+ export declare class SendokaClient {
40
+ readonly baseUrl: string;
41
+ private readonly apiKey;
42
+ private readonly timeout;
43
+ private readonly maxRetries;
44
+ private readonly fetchImpl;
45
+ private readonly retryDelay;
46
+ constructor(options?: SendokaOptions);
47
+ request<T>(method: "GET" | "POST" | "PATCH" | "DELETE", path: string, body?: unknown, options?: RequestOptions): Promise<T>;
48
+ get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T>;
49
+ post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T>;
50
+ patch<T>(path: string, body?: unknown): Promise<T>;
51
+ delete<T>(path: string): Promise<T>;
52
+ /**
53
+ * Walk a cursor-paginated collection, yielding one item at a time.
54
+ *
55
+ * Every list endpoint uses the same `{ data, has_more, next_cursor }` envelope
56
+ * with a compound `(created_at, id)` cursor, so this is worth having once
57
+ * rather than in every caller — and hand-rolled paging is where people
58
+ * reliably introduce an infinite loop by ignoring `has_more`.
59
+ */
60
+ paginate<T>(path: string, query?: Record<string, string | number | boolean | undefined>): AsyncGenerator<T, void, undefined>;
61
+ }
package/dist/client.js ADDED
@@ -0,0 +1,200 @@
1
+ import { SendokaError, SendokaConnectionError } from "./errors.js";
2
+ const DEFAULT_BASE_URL = "https://www.sendoka.com";
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+ const DEFAULT_MAX_RETRIES = 2;
5
+ /** Endpoints where the API honours Idempotency-Key. */
6
+ const IDEMPOTENT_PATHS = [
7
+ "/v1/emails",
8
+ "/v1/sms",
9
+ "/v1/emails/batch",
10
+ "/v1/sms/batch",
11
+ "/v1/phone-numbers",
12
+ "/v1/brands",
13
+ "/v1/campaigns",
14
+ // A billed OTP send. Retried without a key, a slow provider call answered
15
+ // after the timeout texted the end user a second code.
16
+ "/v1/verifications",
17
+ ];
18
+ /**
19
+ * The same, for endpoints with an id in the path. The audience blast is the
20
+ * one where a blind retry costs the most: it can outlast the 30s timeout by
21
+ * minutes, and each retry used to schedule the whole list again.
22
+ */
23
+ const IDEMPOTENT_PATH_PATTERNS = [/^\/v1\/audiences\/[^/?#]+\/send$/];
24
+ function honoursIdempotencyKey(method, path) {
25
+ return (method === "POST" &&
26
+ (IDEMPOTENT_PATHS.includes(path) || IDEMPOTENT_PATH_PATTERNS.some((re) => re.test(path))));
27
+ }
28
+ /**
29
+ * The longest `Retry-After` the SDK will sleep through. The per-minute rate
30
+ * limiter never asks for more than 60s; anything longer is an hourly or daily
31
+ * ceiling, and a request call that silently blocks for an hour is a hang, not a
32
+ * retry. Past this the error goes straight to the caller, `retryAfter` intact.
33
+ */
34
+ const MAX_RETRY_AFTER_SECONDS = 60;
35
+ function sleep(ms) {
36
+ return new Promise((r) => setTimeout(r, ms));
37
+ }
38
+ /**
39
+ * Whether the SDK sends a failed attempt again.
40
+ *
41
+ * `retryable` on the error says the failure is transient; this also asks
42
+ * whether sending the request a second time is SAFE. A timeout, a dropped
43
+ * connection, a 408 or a 5xx does not say whether the server acted: the first
44
+ * attempt may have sent the message. That is fine to retry only when a replay
45
+ * cannot act twice — a GET, or a write carrying an Idempotency-Key the endpoint
46
+ * honours. A 429 is different: the rate limiter refuses before anything runs,
47
+ * so any request may be retried through one.
48
+ */
49
+ function shouldRetry(err, replaySafe) {
50
+ if (!err.retryable)
51
+ return false;
52
+ if (err instanceof SendokaError) {
53
+ if (err.retryAfter !== undefined && err.retryAfter > MAX_RETRY_AFTER_SECONDS)
54
+ return false;
55
+ if (err.status === 429)
56
+ return true;
57
+ }
58
+ return replaySafe;
59
+ }
60
+ export class SendokaClient {
61
+ baseUrl;
62
+ apiKey;
63
+ timeout;
64
+ maxRetries;
65
+ fetchImpl;
66
+ retryDelay;
67
+ constructor(options = {}) {
68
+ const apiKey = options.apiKey ?? process.env.SENDOKA_API_KEY;
69
+ if (!apiKey) {
70
+ throw new Error("Missing API key. Pass { apiKey } or set SENDOKA_API_KEY.");
71
+ }
72
+ this.apiKey = apiKey;
73
+ this.baseUrl = (options.baseUrl ?? process.env.SENDOKA_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
74
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
75
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
76
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
77
+ this.retryDelay =
78
+ options.retryDelay ??
79
+ ((attempt, retryAfterSeconds) =>
80
+ // Honour Retry-After when the server sent one — it knows when the window
81
+ // rolls over, and guessing shorter just burns another attempt.
82
+ retryAfterSeconds !== undefined
83
+ ? retryAfterSeconds * 1000
84
+ : Math.min(2 ** attempt * 250, 8_000));
85
+ }
86
+ async request(method, path, body, options = {}) {
87
+ const url = `${this.baseUrl}/api${path}`;
88
+ // One key for the whole retry sequence, minted once outside the loop. A key
89
+ // per attempt is worse than none: each retry looks like a fresh request and
90
+ // sends a duplicate, which is exactly what the retry was supposed to avoid.
91
+ const honoured = honoursIdempotencyKey(method, path);
92
+ const idempotencyKey = honoured
93
+ ? options.idempotencyKey ?? crypto.randomUUID()
94
+ : options.idempotencyKey;
95
+ // A key on an endpoint that ignores it protects nothing, so it does not
96
+ // make that request safe to send twice.
97
+ const replaySafe = method === "GET" || honoured;
98
+ let lastError;
99
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
100
+ if (attempt > 0) {
101
+ const retryAfter = lastError instanceof SendokaError ? lastError.retryAfter : undefined;
102
+ const backoff = this.retryDelay(attempt, retryAfter);
103
+ if (backoff > 0)
104
+ await sleep(backoff);
105
+ }
106
+ const timer = new AbortController();
107
+ const timeoutId = setTimeout(() => timer.abort(), this.timeout);
108
+ // Caller cancellation and our timeout both have to reach the same fetch.
109
+ const onAbort = () => timer.abort();
110
+ options.signal?.addEventListener("abort", onAbort, { once: true });
111
+ try {
112
+ const res = await this.fetchImpl(url, {
113
+ method,
114
+ headers: {
115
+ Authorization: `Bearer ${this.apiKey}`,
116
+ "Content-Type": "application/json",
117
+ "User-Agent": "sendoka-node/0.2.0",
118
+ ...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
119
+ },
120
+ body: body === undefined ? undefined : JSON.stringify(body),
121
+ signal: timer.signal,
122
+ });
123
+ if (res.ok) {
124
+ if (res.status === 204)
125
+ return undefined;
126
+ return (await res.json());
127
+ }
128
+ const payload = (await res.json().catch(() => ({})));
129
+ const retryAfterHeader = res.headers.get("retry-after");
130
+ lastError = new SendokaError({
131
+ status: res.status,
132
+ type: payload.error?.type ?? "api_error",
133
+ code: payload.error?.code ?? `HTTP_${res.status}`,
134
+ message: payload.error?.message ?? `Request failed with ${res.status}`,
135
+ retryAfter: retryAfterHeader ? Number(retryAfterHeader) : undefined,
136
+ requestId: res.headers.get("x-request-id") ?? undefined,
137
+ idempotencyKey,
138
+ });
139
+ if (!shouldRetry(lastError, replaySafe) || attempt === this.maxRetries)
140
+ throw lastError;
141
+ }
142
+ catch (err) {
143
+ if (err instanceof SendokaError) {
144
+ if (!shouldRetry(err, replaySafe) || attempt === this.maxRetries)
145
+ throw err;
146
+ lastError = err;
147
+ continue;
148
+ }
149
+ // A caller-initiated abort is not a failure to retry through.
150
+ if (options.signal?.aborted)
151
+ throw err;
152
+ lastError = new SendokaConnectionError(err instanceof Error ? err.message : "Network request failed", err, idempotencyKey);
153
+ if (!shouldRetry(lastError, replaySafe) || attempt === this.maxRetries)
154
+ throw lastError;
155
+ }
156
+ finally {
157
+ clearTimeout(timeoutId);
158
+ options.signal?.removeEventListener("abort", onAbort);
159
+ }
160
+ }
161
+ throw lastError ?? new SendokaConnectionError("Request failed");
162
+ }
163
+ get(path, query) {
164
+ const params = new URLSearchParams();
165
+ for (const [k, v] of Object.entries(query ?? {})) {
166
+ if (v !== undefined)
167
+ params.set(k, String(v));
168
+ }
169
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
170
+ return this.request("GET", `${path}${qs}`);
171
+ }
172
+ post(path, body, options) {
173
+ return this.request("POST", path, body, options);
174
+ }
175
+ patch(path, body) {
176
+ return this.request("PATCH", path, body);
177
+ }
178
+ delete(path) {
179
+ return this.request("DELETE", path);
180
+ }
181
+ /**
182
+ * Walk a cursor-paginated collection, yielding one item at a time.
183
+ *
184
+ * Every list endpoint uses the same `{ data, has_more, next_cursor }` envelope
185
+ * with a compound `(created_at, id)` cursor, so this is worth having once
186
+ * rather than in every caller — and hand-rolled paging is where people
187
+ * reliably introduce an infinite loop by ignoring `has_more`.
188
+ */
189
+ async *paginate(path, query = {}) {
190
+ let cursor;
191
+ for (;;) {
192
+ const page = await this.get(path, { ...query, cursor });
193
+ for (const item of page.data)
194
+ yield item;
195
+ if (!page.has_more || !page.next_cursor)
196
+ return;
197
+ cursor = page.next_cursor;
198
+ }
199
+ }
200
+ }
@@ -0,0 +1,53 @@
1
+ export declare class SendokaError extends Error {
2
+ readonly status: number;
3
+ readonly code: string;
4
+ readonly type: string;
5
+ /** Present on 429s when the server sent Retry-After. Seconds. */
6
+ readonly retryAfter?: number;
7
+ readonly requestId?: string;
8
+ /**
9
+ * The Idempotency-Key the request carried, including one the SDK minted.
10
+ * Make the same call again with `{ idempotencyKey }` set to this and the
11
+ * server replays the first attempt's answer instead of acting twice — the
12
+ * way to learn the outcome of a send whose response never arrived.
13
+ */
14
+ readonly idempotencyKey?: string;
15
+ constructor(args: {
16
+ status: number;
17
+ code: string;
18
+ type: string;
19
+ message: string;
20
+ retryAfter?: number;
21
+ requestId?: string;
22
+ idempotencyKey?: string;
23
+ });
24
+ /**
25
+ * Whether retrying the identical request could plausibly succeed.
26
+ *
27
+ * Not whether it is SAFE to: a 5xx on a write does not say the server did
28
+ * nothing. The SDK only retries one through a key the endpoint honours; on
29
+ * any other write, find out what happened before sending it again.
30
+ *
31
+ * 409 is deliberately NOT retryable: it means an idempotency key is in flight
32
+ * or the body changed under one, and hammering it makes both worse.
33
+ *
34
+ * Neither is a 429 that is a QUOTA rather than a RATE. Sendoka answers a
35
+ * monthly plan limit and a tenant monthly cap with 429 (see
36
+ * `enforceUsageLimit`), and no amount of backoff moves the calendar — so
37
+ * retrying burned two extra attempts, and the delay before the caller saw
38
+ * the real reason, on a failure that cannot succeed until next month or
39
+ * until someone raises the cap.
40
+ */
41
+ get retryable(): boolean;
42
+ }
43
+ /**
44
+ * Thrown when the network failed or the request timed out — no HTTP response.
45
+ * The server may still have processed the request; see `idempotencyKey`.
46
+ */
47
+ export declare class SendokaConnectionError extends Error {
48
+ readonly cause?: unknown;
49
+ /** As on SendokaError: replay with this key to learn what the server did. */
50
+ readonly idempotencyKey?: string;
51
+ constructor(message: string, cause?: unknown, idempotencyKey?: string);
52
+ get retryable(): boolean;
53
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The API's error envelope, as a typed exception.
3
+ *
4
+ * Every non-2xx carries `{ error: { type, code, message } }`, and the `code` is
5
+ * the part worth branching on — `type` is a coarse family and `message` is prose
6
+ * that may be reworded. Surfacing all three keeps a caller from having to parse
7
+ * strings to find out whether a send was refused for a suppression or a quota.
8
+ */
9
+ /**
10
+ * Error codes that arrive with a retryable STATUS but are not retryable in
11
+ * fact. The first three are monthly counters answered with 429. The next three
12
+ * are daily ceilings answered with 429 and no Retry-After — a domain's warmup
13
+ * day cap, the provider account's 24h send quota, and the org sandbox sender's
14
+ * 100 recipients a UTC day — so backoff measured in seconds only repeats the
15
+ * work that led to the refusal (on an audience send, re-reading up to 10k
16
+ * contacts) and delays the real reason. TEST_SCHEDULE_LIMIT_EXCEEDED is the
17
+ * same shape for test keys: a cap on test messages waiting to send, freed as
18
+ * they send over minutes, not seconds. TEST_VOLUME_LIMIT_EXCEEDED is test
19
+ * mode's daily allowance of messages, which comes back at 00:00 UTC.
20
+ *
21
+ * AUDIENCE_SEND_INCOMPLETE is a 500 recorded under the request's
22
+ * Idempotency-Key: the blast's job exists and some of its messages may be
23
+ * scheduled. Retrying under the same key only replays it, and retrying under a
24
+ * new one schedules the list a second time over the rows that did land — what
25
+ * to do next is the caller's call, after looking at the job.
26
+ */
27
+ const NON_RETRYABLE_CODES = new Set([
28
+ "USAGE_LIMIT_EXCEEDED",
29
+ "TENANT_QUOTA_EXCEEDED",
30
+ "PLAN_RESOURCE_LIMIT",
31
+ "WARMUP_LIMIT_EXCEEDED",
32
+ "PROVIDER_QUOTA_EXCEEDED",
33
+ "SANDBOX_DAILY_LIMIT",
34
+ "TEST_SCHEDULE_LIMIT_EXCEEDED",
35
+ "TEST_VOLUME_LIMIT_EXCEEDED",
36
+ "AUDIENCE_SEND_INCOMPLETE",
37
+ ]);
38
+ export class SendokaError extends Error {
39
+ status;
40
+ code;
41
+ type;
42
+ /** Present on 429s when the server sent Retry-After. Seconds. */
43
+ retryAfter;
44
+ requestId;
45
+ /**
46
+ * The Idempotency-Key the request carried, including one the SDK minted.
47
+ * Make the same call again with `{ idempotencyKey }` set to this and the
48
+ * server replays the first attempt's answer instead of acting twice — the
49
+ * way to learn the outcome of a send whose response never arrived.
50
+ */
51
+ idempotencyKey;
52
+ constructor(args) {
53
+ super(args.message);
54
+ this.name = "SendokaError";
55
+ this.status = args.status;
56
+ this.code = args.code;
57
+ this.type = args.type;
58
+ this.retryAfter = args.retryAfter;
59
+ this.requestId = args.requestId;
60
+ this.idempotencyKey = args.idempotencyKey;
61
+ }
62
+ /**
63
+ * Whether retrying the identical request could plausibly succeed.
64
+ *
65
+ * Not whether it is SAFE to: a 5xx on a write does not say the server did
66
+ * nothing. The SDK only retries one through a key the endpoint honours; on
67
+ * any other write, find out what happened before sending it again.
68
+ *
69
+ * 409 is deliberately NOT retryable: it means an idempotency key is in flight
70
+ * or the body changed under one, and hammering it makes both worse.
71
+ *
72
+ * Neither is a 429 that is a QUOTA rather than a RATE. Sendoka answers a
73
+ * monthly plan limit and a tenant monthly cap with 429 (see
74
+ * `enforceUsageLimit`), and no amount of backoff moves the calendar — so
75
+ * retrying burned two extra attempts, and the delay before the caller saw
76
+ * the real reason, on a failure that cannot succeed until next month or
77
+ * until someone raises the cap.
78
+ */
79
+ get retryable() {
80
+ if (NON_RETRYABLE_CODES.has(this.code ?? ""))
81
+ return false;
82
+ return this.status === 408 || this.status === 429 || this.status >= 500;
83
+ }
84
+ }
85
+ /**
86
+ * Thrown when the network failed or the request timed out — no HTTP response.
87
+ * The server may still have processed the request; see `idempotencyKey`.
88
+ */
89
+ export class SendokaConnectionError extends Error {
90
+ cause;
91
+ /** As on SendokaError: replay with this key to learn what the server did. */
92
+ idempotencyKey;
93
+ constructor(message, cause, idempotencyKey) {
94
+ super(message);
95
+ this.name = "SendokaConnectionError";
96
+ this.cause = cause;
97
+ this.idempotencyKey = idempotencyKey;
98
+ }
99
+ get retryable() {
100
+ return true;
101
+ }
102
+ }
@@ -0,0 +1,168 @@
1
+ import { SendokaClient, type SendokaOptions, type RequestOptions } from "./client.js";
2
+ import { SendokaError, SendokaConnectionError } from "./errors.js";
3
+ import { verifyWebhookSignature } from "./webhooks.js";
4
+ import type { SendEmailParams, SendSmsParams, BatchResult, MessageRef, Audience, AudienceSendParams, AudienceSendResult, Contact, Verification, VerificationCheck, InboundMessage, Page } from "./types.js";
5
+ export * from "./types.js";
6
+ export { SendokaError, SendokaConnectionError, verifyWebhookSignature };
7
+ export type { SendokaOptions, RequestOptions };
8
+ /**
9
+ * The Sendoka client.
10
+ *
11
+ * Hand-written rather than generated. The generated shape of this API is a flat
12
+ * bag of `postV1EmailsBatch`-style functions, and the parts a caller actually
13
+ * gets wrong — retry without an idempotency key, paging that ignores
14
+ * `has_more`, treating a 409 as retryable — are exactly the parts a generator
15
+ * has nothing to say about.
16
+ *
17
+ * ```ts
18
+ * const sendoka = new Sendoka({ apiKey: process.env.SENDOKA_API_KEY });
19
+ * await sendoka.emails.send({ from: "you@yours.com", to: ["a@b.com"], subject: "Hi", html: "<p>Hi</p>" });
20
+ * ```
21
+ */
22
+ export declare class Sendoka {
23
+ readonly client: SendokaClient;
24
+ constructor(options?: SendokaOptions);
25
+ readonly emails: {
26
+ send: (params: SendEmailParams, options?: RequestOptions) => Promise<MessageRef>;
27
+ /**
28
+ * Up to 100 per call. Idempotent: a replay returns the recorded per-item
29
+ * outcome array verbatim rather than re-attempting, so items that failed the
30
+ * first time stay failed. Retry just those as a new batch.
31
+ */
32
+ sendBatch: (params: SendEmailParams[], options?: RequestOptions) => Promise<BatchResult>;
33
+ get: (id: string) => Promise<MessageRef>;
34
+ /** Only a `scheduled` message can be canceled — one already sent cannot. */
35
+ cancel: (id: string) => Promise<{
36
+ id: string;
37
+ status: string;
38
+ }>;
39
+ list: (query?: Record<string, string | number | undefined>) => Promise<Page<MessageRef>>;
40
+ all: (query?: Record<string, string | number | undefined>) => AsyncGenerator<MessageRef, void, undefined>;
41
+ };
42
+ readonly sms: {
43
+ send: (params: SendSmsParams, options?: RequestOptions) => Promise<MessageRef>;
44
+ sendBatch: (params: SendSmsParams[], options?: RequestOptions) => Promise<BatchResult>;
45
+ get: (id: string) => Promise<MessageRef>;
46
+ cancel: (id: string) => Promise<{
47
+ id: string;
48
+ status: string;
49
+ }>;
50
+ list: (query?: Record<string, string | number | undefined>) => Promise<Page<MessageRef>>;
51
+ all: (query?: Record<string, string | number | undefined>) => AsyncGenerator<MessageRef, void, undefined>;
52
+ };
53
+ readonly audiences: {
54
+ list: () => Promise<Page<Audience>>;
55
+ all: () => AsyncGenerator<Audience, void, undefined>;
56
+ create: (params: {
57
+ slug: string;
58
+ name: string;
59
+ }) => Promise<{
60
+ id: string;
61
+ slug: string;
62
+ }>;
63
+ get: (id: string) => Promise<Audience>;
64
+ update: (id: string, params: {
65
+ name?: string;
66
+ slug?: string;
67
+ }) => Promise<Audience>;
68
+ remove: (id: string) => Promise<{
69
+ id: string;
70
+ deleted: true;
71
+ }>;
72
+ contacts: (id: string, query?: {
73
+ subscribed?: boolean;
74
+ }) => AsyncGenerator<Contact, void, undefined>;
75
+ /**
76
+ * Upserts by email or phone, so re-running a sync matches rather than
77
+ * duplicates. Does NOT re-subscribe anyone who opted out of this list.
78
+ */
79
+ addContacts: (id: string, contacts: Partial<Contact>[]) => Promise<{
80
+ audience_id: string;
81
+ submitted: number;
82
+ contacts_created: number;
83
+ contacts_matched: number;
84
+ }>;
85
+ /**
86
+ * Idempotent: a retry under the same key replays the recorded `job_id`
87
+ * instead of scheduling the list again. A 10k-recipient blast can take
88
+ * longer than the default 30s timeout — the retry then gets 409
89
+ * IDEMPOTENCY_IN_FLIGHT while the first is still scheduling. Call again
90
+ * later with `{ idempotencyKey: err.idempotencyKey }` to get the job id.
91
+ */
92
+ send: (id: string, params: AudienceSendParams, options?: RequestOptions) => Promise<AudienceSendResult>;
93
+ };
94
+ readonly verifications: {
95
+ /**
96
+ * Start an OTP challenge. The destination is never echoed back.
97
+ *
98
+ * Idempotent: a retry under the same key replays the recorded verification
99
+ * rather than texting the user a second code. Use one key per code request,
100
+ * never one per user or destination: for 24h a replay returns the first
101
+ * verification, even after its code has expired, and sends nothing.
102
+ */
103
+ create: (params: {
104
+ channel: "sms" | "email";
105
+ to: string;
106
+ template?: string;
107
+ code_length?: number;
108
+ ttl_seconds?: number;
109
+ max_attempts?: number;
110
+ from?: string;
111
+ subject?: string;
112
+ }, options?: RequestOptions) => Promise<Verification>;
113
+ /**
114
+ * A 503 here means the attempt could not be RECORDED — the user's code is
115
+ * still good and must not be shown as wrong. It is NOT retried for you: a
116
+ * check takes no Idempotency-Key, and a resent check whose first attempt
117
+ * did land would spend a second attempt, or answer `not_found` for the
118
+ * code the first one approved. Ask the user to submit the code again.
119
+ */
120
+ check: (id: string, code: string) => Promise<VerificationCheck>;
121
+ };
122
+ readonly inbound: {
123
+ list: (query?: {
124
+ channel?: "email" | "sms";
125
+ to?: string;
126
+ from?: string;
127
+ q?: string;
128
+ }) => Promise<Page<InboundMessage>>;
129
+ all: (query?: {
130
+ channel?: "email" | "sms";
131
+ }) => AsyncGenerator<InboundMessage, void, undefined>;
132
+ get: (id: string) => Promise<InboundMessage>;
133
+ };
134
+ readonly suppressions: {
135
+ list: (query?: {
136
+ channel?: "email" | "sms";
137
+ }) => Promise<Page<unknown>>;
138
+ add: (params: {
139
+ channel: "email" | "sms";
140
+ value: string;
141
+ reason?: "manual" | "unsubscribe" | "bounce" | "complaint" | "stop";
142
+ /** Omit and it follows `reason`: unsubscribe blocks broadcasts only. */
143
+ stream?: "all" | "broadcast";
144
+ }) => Promise<{
145
+ ok: true;
146
+ }>;
147
+ remove: (channel: "email" | "sms", value: string) => Promise<{
148
+ ok: true;
149
+ }>;
150
+ };
151
+ readonly analytics: {
152
+ /** group_by: 1–3 of day, channel, status, domain, tag. */
153
+ get: (query?: {
154
+ group_by?: string;
155
+ days?: number;
156
+ channel?: "email" | "sms";
157
+ }) => Promise<{
158
+ group_by: string[];
159
+ days: number;
160
+ data: Record<string, unknown>[];
161
+ }>;
162
+ };
163
+ readonly jobs: {
164
+ get: (id: string) => Promise<Record<string, unknown>>;
165
+ cancel: (id: string) => Promise<Record<string, unknown>>;
166
+ };
167
+ }
168
+ export default Sendoka;
package/dist/index.js ADDED
@@ -0,0 +1,107 @@
1
+ import { SendokaClient } from "./client.js";
2
+ import { SendokaError, SendokaConnectionError } from "./errors.js";
3
+ import { verifyWebhookSignature } from "./webhooks.js";
4
+ export * from "./types.js";
5
+ export { SendokaError, SendokaConnectionError, verifyWebhookSignature };
6
+ /**
7
+ * The Sendoka client.
8
+ *
9
+ * Hand-written rather than generated. The generated shape of this API is a flat
10
+ * bag of `postV1EmailsBatch`-style functions, and the parts a caller actually
11
+ * gets wrong — retry without an idempotency key, paging that ignores
12
+ * `has_more`, treating a 409 as retryable — are exactly the parts a generator
13
+ * has nothing to say about.
14
+ *
15
+ * ```ts
16
+ * const sendoka = new Sendoka({ apiKey: process.env.SENDOKA_API_KEY });
17
+ * await sendoka.emails.send({ from: "you@yours.com", to: ["a@b.com"], subject: "Hi", html: "<p>Hi</p>" });
18
+ * ```
19
+ */
20
+ export class Sendoka {
21
+ client;
22
+ constructor(options = {}) {
23
+ this.client = new SendokaClient(options);
24
+ }
25
+ emails = {
26
+ send: (params, options) => this.client.post("/v1/emails", params, options),
27
+ /**
28
+ * Up to 100 per call. Idempotent: a replay returns the recorded per-item
29
+ * outcome array verbatim rather than re-attempting, so items that failed the
30
+ * first time stay failed. Retry just those as a new batch.
31
+ */
32
+ sendBatch: (params, options) => this.client.post("/v1/emails/batch", params, options),
33
+ get: (id) => this.client.get(`/v1/emails/${id}`),
34
+ /** Only a `scheduled` message can be canceled — one already sent cannot. */
35
+ cancel: (id) => this.client.delete(`/v1/emails/${id}`),
36
+ list: (query) => this.client.get("/v1/emails", query),
37
+ all: (query) => this.client.paginate("/v1/emails", query),
38
+ };
39
+ sms = {
40
+ send: (params, options) => this.client.post("/v1/sms", params, options),
41
+ sendBatch: (params, options) => this.client.post("/v1/sms/batch", params, options),
42
+ get: (id) => this.client.get(`/v1/sms/${id}`),
43
+ cancel: (id) => this.client.delete(`/v1/sms/${id}`),
44
+ list: (query) => this.client.get("/v1/sms", query),
45
+ all: (query) => this.client.paginate("/v1/sms", query),
46
+ };
47
+ audiences = {
48
+ list: () => this.client.get("/v1/audiences"),
49
+ all: () => this.client.paginate("/v1/audiences"),
50
+ create: (params) => this.client.post("/v1/audiences", params),
51
+ get: (id) => this.client.get(`/v1/audiences/${id}`),
52
+ update: (id, params) => this.client.patch(`/v1/audiences/${id}`, params),
53
+ remove: (id) => this.client.delete(`/v1/audiences/${id}`),
54
+ contacts: (id, query) => this.client.paginate(`/v1/audiences/${id}/contacts`, query),
55
+ /**
56
+ * Upserts by email or phone, so re-running a sync matches rather than
57
+ * duplicates. Does NOT re-subscribe anyone who opted out of this list.
58
+ */
59
+ addContacts: (id, contacts) => this.client.post(`/v1/audiences/${id}/contacts`, { contacts }),
60
+ /**
61
+ * Idempotent: a retry under the same key replays the recorded `job_id`
62
+ * instead of scheduling the list again. A 10k-recipient blast can take
63
+ * longer than the default 30s timeout — the retry then gets 409
64
+ * IDEMPOTENCY_IN_FLIGHT while the first is still scheduling. Call again
65
+ * later with `{ idempotencyKey: err.idempotencyKey }` to get the job id.
66
+ */
67
+ send: (id, params, options) => this.client.post(`/v1/audiences/${id}/send`, params, options),
68
+ };
69
+ verifications = {
70
+ /**
71
+ * Start an OTP challenge. The destination is never echoed back.
72
+ *
73
+ * Idempotent: a retry under the same key replays the recorded verification
74
+ * rather than texting the user a second code. Use one key per code request,
75
+ * never one per user or destination: for 24h a replay returns the first
76
+ * verification, even after its code has expired, and sends nothing.
77
+ */
78
+ create: (params, options) => this.client.post("/v1/verifications", params, options),
79
+ /**
80
+ * A 503 here means the attempt could not be RECORDED — the user's code is
81
+ * still good and must not be shown as wrong. It is NOT retried for you: a
82
+ * check takes no Idempotency-Key, and a resent check whose first attempt
83
+ * did land would spend a second attempt, or answer `not_found` for the
84
+ * code the first one approved. Ask the user to submit the code again.
85
+ */
86
+ check: (id, code) => this.client.post(`/v1/verifications/${id}/check`, { code }),
87
+ };
88
+ inbound = {
89
+ list: (query) => this.client.get("/v1/inbound", query),
90
+ all: (query) => this.client.paginate("/v1/inbound", query),
91
+ get: (id) => this.client.get(`/v1/inbound/${id}`),
92
+ };
93
+ suppressions = {
94
+ list: (query) => this.client.get("/v1/suppressions", query),
95
+ add: (params) => this.client.post("/v1/suppressions", params),
96
+ remove: (channel, value) => this.client.delete(`/v1/suppressions?channel=${channel}&value=${encodeURIComponent(value)}`),
97
+ };
98
+ analytics = {
99
+ /** group_by: 1–3 of day, channel, status, domain, tag. */
100
+ get: (query) => this.client.get("/v1/analytics", query),
101
+ };
102
+ jobs = {
103
+ get: (id) => this.client.get(`/v1/jobs/${id}`),
104
+ cancel: (id) => this.client.delete(`/v1/jobs/${id}`),
105
+ };
106
+ }
107
+ export default Sendoka;
@@ -0,0 +1,184 @@
1
+ /** Every list endpoint returns this envelope with a compound (created_at, id) cursor. */
2
+ export interface Page<T> {
3
+ data: T[];
4
+ has_more: boolean;
5
+ next_cursor: string | null;
6
+ }
7
+ /**
8
+ * A value for a template `{{placeholder}}`: any JSON. Arrays feed
9
+ * `{{#each items}}`, objects feed dotted paths like `{{order.shipping.city}}`.
10
+ * The API caps nesting at 10 levels below a top-level key and the whole map at
11
+ * 64 KB of serialized JSON (422 VALIDATION_ERROR past either).
12
+ */
13
+ export type TemplateValue = string | number | boolean | null | TemplateValue[] | {
14
+ [key: string]: TemplateValue;
15
+ };
16
+ /**
17
+ * The `variables` map: `TemplateValue`s by name. Typed as `unknown` values on
18
+ * purpose, so an interface-typed object or a `Date` (which serializes to an ISO
19
+ * string) can be passed without a cast; the API validates the JSON it receives.
20
+ */
21
+ export type TemplateVariables = Record<string, unknown>;
22
+ export interface SendEmailParams {
23
+ from: string;
24
+ to: string[];
25
+ subject: string;
26
+ html?: string;
27
+ text?: string;
28
+ cc?: string[];
29
+ bcc?: string[];
30
+ reply_to?: string[];
31
+ headers?: Record<string, string>;
32
+ tags?: string[];
33
+ metadata?: Record<string, string>;
34
+ attachments?: {
35
+ filename: string;
36
+ content?: string;
37
+ url?: string;
38
+ content_type?: string;
39
+ }[];
40
+ /** ISO 8601. Pair with `scheduled_at_tz` to schedule in a local zone. */
41
+ scheduled_at?: string;
42
+ scheduled_local?: string;
43
+ scheduled_at_tz?: string;
44
+ template?: string;
45
+ variables?: TemplateVariables;
46
+ track_opens?: boolean;
47
+ track_clicks?: boolean;
48
+ }
49
+ export interface SendSmsParams {
50
+ from?: string;
51
+ to: string;
52
+ body: string;
53
+ /** MMS attachments as S3 URIs (`s3://bucket/key`). https links are rejected. */
54
+ media_url?: string[];
55
+ tags?: string[];
56
+ metadata?: Record<string, string>;
57
+ scheduled_at?: string;
58
+ scheduled_local?: string;
59
+ scheduled_at_tz?: string;
60
+ template?: string;
61
+ variables?: TemplateVariables;
62
+ /** Required for +91 under DLT enforcement. */
63
+ dlt_template_id?: string;
64
+ from_pool?: string;
65
+ }
66
+ /**
67
+ * Body of `audiences.send`. Each recipient's contact fields are available as
68
+ * `{{contact.name}}` / `{{contact.first_name}}` / `{{contact.email}}` /
69
+ * `{{contact.phone}}` / `{{contact.id}}` (and flat `{{name}}`, `{{first_name}}`,
70
+ * `{{email}}`, `{{phone}}`). Precedence, lowest first: contact fields, then
71
+ * `variables`, then the contact's own metadata.
72
+ */
73
+ export interface AudienceSendParams {
74
+ channel: "email" | "sms";
75
+ from: string;
76
+ template: string;
77
+ variables?: TemplateVariables;
78
+ scheduled_at?: string;
79
+ scheduled_local?: string;
80
+ scheduled_at_tz?: string;
81
+ /** 0–1440. Spread the blast evenly over this many minutes. */
82
+ ramp_minutes?: number;
83
+ /** Email only, 2–5 subject lines for an A/B test. */
84
+ subject_variants?: string[];
85
+ /** Required for SMS blasts that reach +91 under DLT enforcement. */
86
+ dlt_template_id?: string;
87
+ /** Email only. Both default to true for blasts. */
88
+ track_opens?: boolean;
89
+ track_clicks?: boolean;
90
+ }
91
+ /** What `audiences.send` answers once the blast's rows are scheduled. */
92
+ export interface AudienceSendResult {
93
+ /** Poll with `GET /v1/jobs/{id}`, stop with `DELETE /v1/jobs/{id}`. */
94
+ job_id: string;
95
+ audience_id: string;
96
+ /** Members still subscribed to the audience. */
97
+ total: number;
98
+ /** Messages created, one per sendable recipient. */
99
+ scheduled: number;
100
+ /** `total - scheduled`: suppressed, or no address on the blast's channel. */
101
+ suppressed: number;
102
+ /** SMS only (0 for email): rows moved out of the destination's quiet hours. Counted inside `scheduled`. */
103
+ deferred_quiet_hours: number;
104
+ starts_at: string;
105
+ /** When the last row fires. */
106
+ ends_at: string;
107
+ }
108
+ export interface MessageRef {
109
+ id: string;
110
+ status: "queued" | "scheduled" | "sending" | "sent" | "delivered" | "bounced" | "failed" | "canceled";
111
+ created_at?: string;
112
+ /** Present when some recipients were dropped by the suppression list. */
113
+ suppressed_recipients?: string[];
114
+ }
115
+ export interface BatchResult {
116
+ data: {
117
+ id?: string;
118
+ status?: string;
119
+ error?: {
120
+ code: string;
121
+ message: string;
122
+ };
123
+ }[];
124
+ total: number;
125
+ succeeded: number;
126
+ failed: number;
127
+ }
128
+ export interface Audience {
129
+ id: string;
130
+ slug: string;
131
+ name: string;
132
+ tenant_id: string | null;
133
+ /** Only on the single-audience read. */
134
+ contact_count?: number;
135
+ /** What a blast would actually reach. The gap from contact_count is churn. */
136
+ subscribed_count?: number;
137
+ created_at: string;
138
+ }
139
+ export interface Contact {
140
+ id: string;
141
+ email: string | null;
142
+ phone: string | null;
143
+ name: string | null;
144
+ metadata: Record<string, string> | null;
145
+ /** Per-list. A contact can be subscribed to one audience and not another. */
146
+ subscribed?: boolean;
147
+ unsubscribed_at?: string | null;
148
+ unsubscribe_source?: string | null;
149
+ created_at: string;
150
+ }
151
+ export interface Verification {
152
+ id: string;
153
+ status: "pending";
154
+ channel: "sms" | "email";
155
+ expires_at: string;
156
+ }
157
+ export interface VerificationCheck {
158
+ id: string;
159
+ status: "approved" | "denied";
160
+ reason?: "invalid" | "expired" | "not_found" | "max_attempts";
161
+ }
162
+ export interface InboundMessage {
163
+ id: string;
164
+ channel: "email" | "sms";
165
+ tenant_id: string | null;
166
+ from: string;
167
+ to: string;
168
+ subject?: string | null;
169
+ /** List responses only — the detail route returns the full body instead. */
170
+ preview?: string | null;
171
+ text?: string | null;
172
+ html?: string | null;
173
+ headers?: Record<string, unknown> | null;
174
+ attachments?: unknown;
175
+ conversation_id?: string | null;
176
+ created_at: string;
177
+ }
178
+ /** Every event name the platform can deliver. */
179
+ export type WebhookEvent = "message.sent" | "message.delivered" | "message.bounced" | "message.failed" | "message.complained" | "message.opened" | "message.clicked" | "message.unsubscribed" | "domain.verified" | "domain.unverified" | "domain.removed" | "domain.warmup_started" | "brand.verified" | "brand.unverified" | "brand.removed" | "campaign.verified" | "campaign.unverified" | "campaign.removed" | "phone_number.verified" | "phone_number.unverified" | "phone_number.released" | "inbound.sms" | "inbound.email" | "dlt.template_approved" | "dlt.template_rejected" | "job.started" | "job.completed" | "job.canceled";
180
+ export interface WebhookPayload<T = Record<string, unknown>> {
181
+ event: WebhookEvent;
182
+ data: T;
183
+ timestamp: string;
184
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ export interface VerifyWebhookOptions {
2
+ /** The RAW request body. Not a re-serialized object — see below. */
3
+ payload: string;
4
+ /** `X-Sendoka-Signature-V2`. May be a comma-separated list during rotation. */
5
+ signature: string;
6
+ /** `X-Sendoka-Timestamp`. */
7
+ timestamp: string;
8
+ secret: string;
9
+ toleranceSeconds?: number;
10
+ }
11
+ /**
12
+ * Verify a webhook delivery.
13
+ *
14
+ * **`payload` must be the raw body bytes as received.** `JSON.stringify` of a
15
+ * parsed object is not the same string — key order and number formatting both
16
+ * move — and every signature check will fail in a way that looks like a wrong
17
+ * secret. In Express, that means `express.raw({ type: 'application/json' })`;
18
+ * in a Next.js route handler, `await req.text()` BEFORE `req.json()`.
19
+ *
20
+ * Verifies `X-Sendoka-Signature-V2` (HMAC over `${timestamp}.${body}`) rather
21
+ * than the legacy `X-Sendoka-Signature` (HMAC over the body alone): without the
22
+ * timestamp in the signed material, a captured delivery replays forever.
23
+ *
24
+ * A comma-separated signature list is normal during a secret rotation — the
25
+ * sender signs with both the new and previous secret so a receiver mid-deploy
26
+ * still verifies one of them. Any match counts.
27
+ */
28
+ export declare function verifyWebhookSignature(options: VerifyWebhookOptions): boolean;
@@ -0,0 +1,49 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ /**
3
+ * Default replay tolerance. Five minutes is wide enough for ordinary clock skew
4
+ * between two machines and narrow enough that a captured delivery is not
5
+ * replayable an hour later.
6
+ */
7
+ const DEFAULT_TOLERANCE_SECONDS = 300;
8
+ /**
9
+ * Verify a webhook delivery.
10
+ *
11
+ * **`payload` must be the raw body bytes as received.** `JSON.stringify` of a
12
+ * parsed object is not the same string — key order and number formatting both
13
+ * move — and every signature check will fail in a way that looks like a wrong
14
+ * secret. In Express, that means `express.raw({ type: 'application/json' })`;
15
+ * in a Next.js route handler, `await req.text()` BEFORE `req.json()`.
16
+ *
17
+ * Verifies `X-Sendoka-Signature-V2` (HMAC over `${timestamp}.${body}`) rather
18
+ * than the legacy `X-Sendoka-Signature` (HMAC over the body alone): without the
19
+ * timestamp in the signed material, a captured delivery replays forever.
20
+ *
21
+ * A comma-separated signature list is normal during a secret rotation — the
22
+ * sender signs with both the new and previous secret so a receiver mid-deploy
23
+ * still verifies one of them. Any match counts.
24
+ */
25
+ export function verifyWebhookSignature(options) {
26
+ const { payload, signature, timestamp, secret } = options;
27
+ const tolerance = options.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
28
+ const ts = Number(timestamp);
29
+ if (!Number.isFinite(ts))
30
+ return false;
31
+ // Both directions. A delivery timestamped in the future is as suspect as one
32
+ // timestamped in the past — a one-sided check is defeated by a clock the
33
+ // attacker controls.
34
+ const skew = Math.abs(Date.now() / 1000 - ts);
35
+ if (skew > tolerance)
36
+ return false;
37
+ const expected = createHmac("sha256", secret).update(`${timestamp}.${payload}`).digest("hex");
38
+ const expectedBuf = Buffer.from(expected);
39
+ // Any of the comma-separated candidates may match. Compared with
40
+ // timingSafeEqual, and length-checked first because timingSafeEqual throws on
41
+ // a length mismatch rather than returning false.
42
+ return signature
43
+ .split(",")
44
+ .map((s) => s.trim())
45
+ .some((candidate) => {
46
+ const candidateBuf = Buffer.from(candidate);
47
+ return (candidateBuf.length === expectedBuf.length && timingSafeEqual(candidateBuf, expectedBuf));
48
+ });
49
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@sendoka/node",
3
+ "version": "0.2.0",
4
+ "description": "Official Node.js / TypeScript SDK for the Sendoka email + SMS API.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": ["dist", "README.md"],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "dev": "tsc -p tsconfig.json --watch",
19
+ "prepack": "npm run build"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/fareed010/Sendoka.git",
27
+ "directory": "packages/sdk-node"
28
+ },
29
+ "dependencies": {},
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "typescript": "^5.6.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "keywords": ["sendoka", "email", "sms", "otp", "api"]
38
+ }