@sneekin/sdk 0.3.2 → 1.1.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.
@@ -0,0 +1,441 @@
1
+ // src/core/client.ts
2
+ var SneekApiError = class extends Error {
3
+ constructor(status, type, title, detail) {
4
+ super(`${title}${detail ? `: ${detail}` : ""} [${status}]`);
5
+ this.status = status;
6
+ this.type = type;
7
+ this.title = title;
8
+ this.detail = detail;
9
+ this.name = "SneekApiError";
10
+ }
11
+ status;
12
+ type;
13
+ title;
14
+ detail;
15
+ };
16
+ var SneekClient = class {
17
+ apiUrl;
18
+ apiKey;
19
+ fetchImpl;
20
+ maxRetries;
21
+ retryBaseMs;
22
+ constructor(options) {
23
+ if (!options.apiUrl) throw new Error("apiUrl is required");
24
+ if (!options.apiKey) throw new Error("apiKey is required");
25
+ this.apiUrl = options.apiUrl.replace(/\/+$/, "");
26
+ this.apiKey = options.apiKey;
27
+ this.fetchImpl = options.fetch ?? fetch;
28
+ this.maxRetries = options.maxRetries ?? 2;
29
+ this.retryBaseMs = options.retryBaseMs ?? 200;
30
+ }
31
+ async request(method, path, body, extraHeaders) {
32
+ const raw = body === void 0 ? void 0 : JSON.stringify(body);
33
+ let attempt = 0;
34
+ let lastError;
35
+ for (; ; ) {
36
+ try {
37
+ const response = await this.fetchImpl(`${this.apiUrl}${path}`, {
38
+ method,
39
+ headers: {
40
+ "content-type": "application/json",
41
+ authorization: `Bearer ${this.apiKey}`,
42
+ ...extraHeaders ?? {}
43
+ },
44
+ body: raw
45
+ });
46
+ const text = await response.text();
47
+ const parsed = text ? parseJson(text) : void 0;
48
+ if (!response.ok) {
49
+ const problem = asProblem(parsed);
50
+ const error = new SneekApiError(
51
+ response.status,
52
+ problem.type ?? "about:blank",
53
+ problem.title ?? response.statusText,
54
+ problem.detail
55
+ );
56
+ if (response.status >= 500 && attempt < this.maxRetries) {
57
+ lastError = error;
58
+ await this.backoff(attempt++);
59
+ continue;
60
+ }
61
+ throw error;
62
+ }
63
+ return parsed;
64
+ } catch (error) {
65
+ if (error instanceof SneekApiError) throw error;
66
+ if (attempt < this.maxRetries) {
67
+ lastError = error;
68
+ await this.backoff(attempt++);
69
+ continue;
70
+ }
71
+ throw lastError ?? error;
72
+ }
73
+ }
74
+ }
75
+ backoff(attempt) {
76
+ return new Promise((resolve) => setTimeout(resolve, this.retryBaseMs * 2 ** attempt));
77
+ }
78
+ };
79
+ function parseJson(text) {
80
+ try {
81
+ return JSON.parse(text);
82
+ } catch {
83
+ return void 0;
84
+ }
85
+ }
86
+ function asProblem(value) {
87
+ if (typeof value !== "object" || value === null) return {};
88
+ const problem = value;
89
+ return {
90
+ type: typeof problem.type === "string" ? problem.type : void 0,
91
+ title: typeof problem.title === "string" ? problem.title : void 0,
92
+ detail: typeof problem.detail === "string" ? problem.detail : void 0
93
+ };
94
+ }
95
+
96
+ // src/core/derive-keys.ts
97
+ import { hkdfSync } from "crypto";
98
+ var KEY_LABELS = {
99
+ qrSigningKey: "sneek-qr-v1",
100
+ webhookSigningKey: "sneek-webhook-v1",
101
+ proofKey: "sneek-proof-v1"
102
+ };
103
+ function deriveKeys(apiKey) {
104
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
105
+ throw new TypeError("apiKey must be a nonempty string");
106
+ }
107
+ const inputKeyMaterial = Buffer.from(apiKey, "utf8");
108
+ const derive = (label) => Buffer.from(
109
+ hkdfSync("sha256", inputKeyMaterial, Buffer.alloc(0), label, 32)
110
+ );
111
+ return {
112
+ qrSigningKey: derive(KEY_LABELS.qrSigningKey),
113
+ webhookSigningKey: derive(KEY_LABELS.webhookSigningKey),
114
+ proofKey: derive(KEY_LABELS.proofKey)
115
+ };
116
+ }
117
+
118
+ // src/verify/handler.ts
119
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "crypto";
120
+ import { createRemoteJWKSet, jwtVerify } from "jose";
121
+
122
+ // src/verify/auth-schema.ts
123
+ import { z } from "zod";
124
+ var BrowserProof = z.string().min(1).max(4096);
125
+ var VerificationId = z.string().uuid();
126
+ var QrReference = z.string().regex(/^[0-9a-f]{64}$/);
127
+ var ConfigActionSchema = z.object({ action: z.literal("config") }).strict();
128
+ var RequestActionSchema = z.object({
129
+ action: z.literal("request"),
130
+ identifier: z.string().min(3).max(254)
131
+ }).strict();
132
+ var SwitchActionSchema = z.object({
133
+ action: z.literal("switch"),
134
+ requestId: VerificationId,
135
+ channel: z.enum(["sms", "whatsapp", "email", "push"]),
136
+ proof: BrowserProof
137
+ }).strict();
138
+ var VerifyCodeActionSchema = z.object({
139
+ action: z.literal("verify"),
140
+ requestId: VerificationId,
141
+ code: z.string().min(4).max(12).optional(),
142
+ proof: BrowserProof
143
+ }).strict();
144
+ var VerifyQrActionSchema = z.object({
145
+ action: z.literal("verify"),
146
+ requestId: VerificationId,
147
+ ref: QrReference,
148
+ bindingProof: BrowserProof
149
+ }).strict();
150
+ var CreateQrActionSchema = z.object({ action: z.literal("create") }).strict();
151
+ var QrStatusActionSchema = z.object({
152
+ action: z.literal("status"),
153
+ ref: QrReference,
154
+ bindingProof: BrowserProof
155
+ }).strict();
156
+ var QrWebhookEventSchema = z.object({
157
+ type: z.literal("qr.approved"),
158
+ applicationId: z.string().uuid(),
159
+ ref: QrReference,
160
+ requestId: VerificationId
161
+ }).strict();
162
+ var SneekVerifyActionSchema = z.union([
163
+ ConfigActionSchema,
164
+ RequestActionSchema,
165
+ SwitchActionSchema,
166
+ VerifyCodeActionSchema,
167
+ VerifyQrActionSchema,
168
+ CreateQrActionSchema,
169
+ QrStatusActionSchema
170
+ ]);
171
+
172
+ // src/verify/handler.ts
173
+ var SneekVerifyHandler = class {
174
+ client;
175
+ allowedOrigins;
176
+ keys;
177
+ approvalStore;
178
+ onVerified;
179
+ jwksByIssuer = /* @__PURE__ */ new Map();
180
+ constructor(options) {
181
+ this.client = new SneekClient({
182
+ apiUrl: options.apiUrl,
183
+ apiKey: options.apiKey,
184
+ fetch: options.fetch
185
+ });
186
+ this.allowedOrigins = new Set(options.allowedOrigins.map((origin) => new URL(origin).origin));
187
+ this.keys = deriveKeys(options.apiKey);
188
+ this.approvalStore = options.approvalStore;
189
+ this.onVerified = options.onVerified;
190
+ }
191
+ async handle(request) {
192
+ const origin = this.getAllowedOrigin(request);
193
+ if (request.method !== "POST" || !origin) {
194
+ return jsonResponse({ error: "Forbidden" }, 403);
195
+ }
196
+ let body;
197
+ try {
198
+ body = await request.json();
199
+ } catch {
200
+ return jsonResponse({ error: "Invalid JSON body" }, 400);
201
+ }
202
+ const action = SneekVerifyActionSchema.safeParse(body);
203
+ if (!action.success) return jsonResponse({ error: "Invalid auth action" }, 400);
204
+ switch (action.data.action) {
205
+ case "config":
206
+ return this.config(origin);
207
+ case "request":
208
+ return this.startVerification(action.data.identifier, origin);
209
+ case "switch":
210
+ return this.switchVerification(action.data, origin);
211
+ case "verify":
212
+ return this.verify(action.data, origin);
213
+ case "create":
214
+ return this.createQr(origin);
215
+ case "status":
216
+ return this.qrStatus(action.data.ref, action.data.bindingProof, origin);
217
+ }
218
+ }
219
+ async handleWebhook(request) {
220
+ if (request.method !== "POST") return jsonResponse({ error: "Invalid webhook" }, 405);
221
+ const contentLength = Number(request.headers.get("content-length") ?? 0);
222
+ if (contentLength > 16384) return jsonResponse({ error: "Invalid webhook" }, 413);
223
+ const signature = request.headers.get("sneek-signature") ?? "";
224
+ const match = /^t=(\d{1,12}),v1=([A-Za-z0-9_-]{43})$/.exec(signature);
225
+ if (!match) return jsonResponse({ error: "Invalid webhook signature" }, 401);
226
+ const timestamp = Number(match[1]);
227
+ const now = Math.floor(Date.now() / 1e3);
228
+ if (!Number.isSafeInteger(timestamp) || Math.abs(now - timestamp) > 300) {
229
+ return jsonResponse({ error: "Expired webhook signature" }, 401);
230
+ }
231
+ let body;
232
+ try {
233
+ const text = await request.text();
234
+ if (Buffer.byteLength(text, "utf8") > 16384) return jsonResponse({ error: "Invalid webhook" }, 413);
235
+ body = JSON.parse(text);
236
+ } catch {
237
+ return jsonResponse({ error: "Invalid webhook body" }, 400);
238
+ }
239
+ const event = QrWebhookEventSchema.safeParse(body);
240
+ if (!event.success || !this.isValidWebhookSignature(timestamp, event.data, match[2])) {
241
+ return jsonResponse({ error: "Invalid webhook signature" }, 401);
242
+ }
243
+ const session = await this.approvalStore.readQrSession(event.data.ref);
244
+ if (!session || session.applicationId !== event.data.applicationId || session.expiresAt <= Date.now()) {
245
+ return jsonResponse({ error: "Unknown or expired QR approval" }, 401);
246
+ }
247
+ const recorded = await this.approvalStore.recordQrApproval(event.data, Date.now());
248
+ if (recorded === "missing") return jsonResponse({ error: "Unknown or expired QR approval" }, 401);
249
+ return new Response(null, { status: 204 });
250
+ }
251
+ getAllowedOrigin(request) {
252
+ const origin = request.headers.get("origin");
253
+ if (!origin || origin === "null") return null;
254
+ try {
255
+ const parsed = new URL(origin);
256
+ return parsed.origin === origin && this.allowedOrigins.has(parsed.origin) && new URL(request.url).origin === parsed.origin ? parsed.origin : null;
257
+ } catch {
258
+ return null;
259
+ }
260
+ }
261
+ async config(origin) {
262
+ const partner = await this.client.request("GET", "/api/verify/qr/partner-config");
263
+ const enabled = partner.origins.includes(origin);
264
+ return jsonResponse({
265
+ origin,
266
+ enabled,
267
+ ...enabled ? { applicationId: partner.applicationId } : {}
268
+ }, 200);
269
+ }
270
+ async startVerification(identifier, origin) {
271
+ const result = await this.client.request("POST", "/api/verify", { identifier });
272
+ return this.startResponse(result, origin);
273
+ }
274
+ async switchVerification(action, origin) {
275
+ if (!this.isValidProof(action.proof, action.requestId, origin)) {
276
+ return jsonResponse({ error: "Invalid or expired browser proof" }, 403);
277
+ }
278
+ const result = await this.client.request(
279
+ "POST",
280
+ `/api/verify/${action.requestId}/switch`,
281
+ { channel: action.channel }
282
+ );
283
+ return this.startResponse(result, origin);
284
+ }
285
+ async verify(action, origin) {
286
+ if ("proof" in action) {
287
+ if (!this.isValidProof(action.proof, action.requestId, origin)) {
288
+ return jsonResponse({ error: "Invalid or expired browser proof" }, 403);
289
+ }
290
+ } else {
291
+ const session = await this.qrSession(action.ref, action.bindingProof, origin);
292
+ if (!session || session.requestId !== action.requestId) {
293
+ return jsonResponse({ error: "QR approval is not bound to this browser" }, 403);
294
+ }
295
+ }
296
+ const body = "proof" in action && action.code !== void 0 ? { code: action.code } : {};
297
+ const result = await this.client.request(
298
+ "POST",
299
+ `/api/verify/${action.requestId}/check`,
300
+ body
301
+ );
302
+ if (result.status !== "verified") {
303
+ return jsonResponse({ error: "This verification requires additional account setup" }, 403);
304
+ }
305
+ const partner = await this.client.request("GET", "/api/verify/qr/partner-config");
306
+ let payload;
307
+ try {
308
+ const jwks = this.jwksByIssuer.get(partner.issuer) ?? createRemoteJWKSet(new URL("/oauth/jwks", partner.issuer));
309
+ this.jwksByIssuer.set(partner.issuer, jwks);
310
+ ({ payload } = await jwtVerify(result.idToken, jwks, {
311
+ issuer: partner.issuer,
312
+ audience: partner.clientId
313
+ }));
314
+ } catch {
315
+ return jsonResponse({ error: "Sneek verification token is invalid" }, 502);
316
+ }
317
+ const claims = this.verifiedClaims(payload, action.requestId);
318
+ if (!claims) return jsonResponse({ error: "Sneek verification token is invalid" }, 502);
319
+ if (!await this.approvalStore.claimJti(claims.jti, claims.expiresAt * 1e3)) {
320
+ return jsonResponse({ error: "Sneek verification token was already used" }, 401);
321
+ }
322
+ const verified = {
323
+ identifier: claims.identifier,
324
+ kind: claims.kind,
325
+ method: claims.method,
326
+ verifiedAt: new Date(claims.verifiedAt * 1e3).toISOString(),
327
+ requestId: action.requestId
328
+ };
329
+ await this.onVerified(verified);
330
+ return jsonResponse({ status: "verified", ...verified }, 200);
331
+ }
332
+ verifiedClaims(payload, requestId) {
333
+ const requestClaim = payload.verification_request_id;
334
+ const issuedAt = payload.auth_time ?? payload.iat;
335
+ const methods = payload.amr;
336
+ const email = payload.email;
337
+ const mobile = payload.phone_number;
338
+ const hasEmail = typeof email === "string" && payload.email_verified === true;
339
+ const hasMobile = typeof mobile === "string" && payload.phone_number_verified === true;
340
+ if (payload.sub !== requestId || requestClaim !== requestId || typeof payload.exp !== "number" || typeof issuedAt !== "number" || typeof payload.jti !== "string" || !payload.jti || !Array.isArray(methods) || methods.length === 0 || methods.some((method) => typeof method !== "string" || !method) || hasEmail === hasMobile) return null;
341
+ return {
342
+ identifier: hasEmail ? email : mobile,
343
+ kind: hasEmail ? "email" : "mobile",
344
+ method: methods.join(" "),
345
+ verifiedAt: issuedAt,
346
+ expiresAt: payload.exp,
347
+ jti: payload.jti
348
+ };
349
+ }
350
+ startResponse(result, origin) {
351
+ const expiresAt = Date.parse(result.expiresAt);
352
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
353
+ return jsonResponse({ error: "Verification response is invalid or expired" }, 502);
354
+ }
355
+ return jsonResponse({
356
+ requestId: result.id,
357
+ channel: result.channel,
358
+ maskedIdentifier: result.maskedIdentifier,
359
+ expiresAt: result.expiresAt,
360
+ ...result.instruction ? { instruction: result.instruction } : {},
361
+ ...result.alternatives ? { alternatives: result.alternatives } : {},
362
+ ...result.challengeNumber ? { challengeNumber: result.challengeNumber } : {},
363
+ proof: this.createProof(result.id, origin, expiresAt)
364
+ }, 200);
365
+ }
366
+ createProof(requestId, origin, expiresAt) {
367
+ const payload = Buffer.from(JSON.stringify({
368
+ requestId,
369
+ origin,
370
+ expiresAt,
371
+ nonce: randomBytes(16).toString("base64url")
372
+ })).toString("base64url");
373
+ const signature = createHmac("sha256", this.keys.proofKey).update(payload).digest("base64url");
374
+ return `${payload}.${signature}`;
375
+ }
376
+ isValidProof(proof, requestId, origin) {
377
+ const [payload, signature, ...extra] = proof.split(".");
378
+ if (!payload || !signature || extra.length > 0) return false;
379
+ const expected = createHmac("sha256", this.keys.proofKey).update(payload).digest();
380
+ const actual = Buffer.from(signature, "base64url");
381
+ if (actual.length !== expected.length || !timingSafeEqual(expected, actual)) return false;
382
+ try {
383
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
384
+ return decoded.requestId === requestId && decoded.origin === origin && typeof decoded.expiresAt === "number" && decoded.expiresAt > Date.now() && typeof decoded.nonce === "string";
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+ async createQr(origin) {
390
+ const partner = await this.partnerConfig();
391
+ if (!partner.origins.includes(origin)) return jsonResponse({ error: "Origin is not registered for QR verification" }, 403);
392
+ const nonce = randomBytes(32).toString("hex");
393
+ const ref = createHash("sha256").update(nonce).digest("hex");
394
+ const issuedAt = Math.floor(Date.now() / 1e3);
395
+ const expiresAt = (issuedAt + partner.qrTtlSeconds) * 1e3;
396
+ const bindingProof = randomBytes(32).toString("base64url");
397
+ const signature = createHmac("sha256", this.keys.qrSigningKey).update(["sneek-qr-v1", partner.applicationId, nonce, origin, String(issuedAt)].join("\n")).digest().subarray(0, 16).toString("base64url");
398
+ await this.approvalStore.createQrSession({
399
+ ref,
400
+ applicationId: partner.applicationId,
401
+ origin,
402
+ bindingProofHash: this.hash(bindingProof),
403
+ expiresAt
404
+ });
405
+ return jsonResponse({ ref, nonce, issuedAt, expiresAt, signature, bindingProof }, 200);
406
+ }
407
+ async qrStatus(ref, bindingProof, origin) {
408
+ const session = await this.qrSession(ref, bindingProof, origin);
409
+ if (!session) return jsonResponse({ error: "QR approval is not bound to this browser" }, 403);
410
+ if (!session.requestId) return jsonResponse({ status: "pending" }, 200);
411
+ return jsonResponse({ status: "approved", requestId: session.requestId }, 200);
412
+ }
413
+ async qrSession(ref, bindingProof, origin) {
414
+ const session = await this.approvalStore.readQrSession(ref);
415
+ if (!session || session.origin !== origin || session.expiresAt <= Date.now()) return null;
416
+ const expected = Buffer.from(session.bindingProofHash, "hex");
417
+ const actual = Buffer.from(this.hash(bindingProof), "hex");
418
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
419
+ return session;
420
+ }
421
+ async partnerConfig() {
422
+ return this.client.request("GET", "/api/verify/qr/partner-config");
423
+ }
424
+ isValidWebhookSignature(timestamp, event, signature) {
425
+ const expected = createHmac("sha256", this.keys.webhookSigningKey).update(["sneek-webhook-v1", String(timestamp), event.type, event.applicationId, event.ref, event.requestId].join("\n")).digest();
426
+ const actual = Buffer.from(signature, "base64url");
427
+ return actual.length === expected.length && timingSafeEqual(expected, actual);
428
+ }
429
+ hash(value) {
430
+ return createHash("sha256").update(value).digest("hex");
431
+ }
432
+ };
433
+ function jsonResponse(body, status) {
434
+ return Response.json(body, { status });
435
+ }
436
+ export {
437
+ SneekApiError,
438
+ SneekClient,
439
+ SneekVerifyHandler,
440
+ deriveKeys
441
+ };
package/package.json CHANGED
@@ -1,49 +1,69 @@
1
1
  {
2
- "name": "@sneekin/sdk",
3
- "version": "0.3.2",
4
- "publishConfig": {
5
- "access": "public"
6
- },
7
- "description": "Node.js SDK for the Sneek Messaging API",
8
- "homepage": "https://sneek.in/docs",
9
- "bugs": {
10
- "url": "https://sneek.in/docs"
11
- },
12
- "main": "dist/index.js",
13
- "module": "dist/index.mjs",
14
- "types": "dist/index.d.ts",
15
- "exports": {
16
- ".": {
17
- "types": "./dist/index.d.ts",
18
- "import": "./dist/index.mjs",
19
- "require": "./dist/index.js"
2
+ "name": "@sneekin/sdk",
3
+ "version": "1.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Server-side SDK for Sneek verification, messaging, and payments",
8
+ "homepage": "https://sneek.in/docs",
9
+ "bugs": {
10
+ "url": "https://sneek.in/docs"
11
+ },
12
+ "type": "module",
13
+ "main": "./dist/index.cjs",
14
+ "module": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "require": "./dist/index.cjs"
21
+ },
22
+ "./verify": {
23
+ "types": "./dist/verify/index.d.ts",
24
+ "import": "./dist/verify/index.js",
25
+ "require": "./dist/verify/index.cjs"
26
+ },
27
+ "./messages": {
28
+ "types": "./dist/messages/index.d.ts",
29
+ "import": "./dist/messages/index.js",
30
+ "require": "./dist/messages/index.cjs"
31
+ },
32
+ "./pay": {
33
+ "types": "./dist/pay/index.d.ts",
34
+ "import": "./dist/pay/index.js",
35
+ "require": "./dist/pay/index.cjs"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "sideEffects": false,
42
+ "scripts": {
43
+ "build": "tsup && tsc -p tsconfig.json --emitDeclarationOnly",
44
+ "type-check": "tsc -p tsconfig.json --noEmit",
45
+ "test": "node --test --import tsx test/*.test.ts test/*.test.mjs",
46
+ "test:package": "node --test test/package-built.test.mjs"
47
+ },
48
+ "keywords": [
49
+ "sneek",
50
+ "verification",
51
+ "messaging",
52
+ "payments"
53
+ ],
54
+ "author": "Abblor Tech Pvt Ltd <abblorltd@gmail.com> (https://sneek.in)",
55
+ "license": "UNLICENSED",
56
+ "dependencies": {
57
+ "jose": "6.2.8",
58
+ "zod": "3.25.76"
59
+ },
60
+ "devDependencies": {
61
+ "@types/node": "22.19.17",
62
+ "tsup": "8.5.1",
63
+ "tsx": "4.21.0",
64
+ "typescript": "6.0.3"
65
+ },
66
+ "engines": {
67
+ "node": ">=20"
20
68
  }
21
- },
22
- "files": [
23
- "dist"
24
- ],
25
- "scripts": {
26
- "build": "tsup",
27
- "type-check": "tsc --noEmit",
28
- "test": "node --test --import tsx test/*.test.ts"
29
- },
30
- "keywords": [
31
- "sneek",
32
- "messaging",
33
- "sms",
34
- "whatsapp",
35
- "email",
36
- "otp"
37
- ],
38
- "author": "Abblor Tech Pvt Ltd <abblorltd@gmail.com> (https://sneek.in)",
39
- "license": "UNLICENSED",
40
- "devDependencies": {
41
- "@types/node": "^20.11.0",
42
- "tsup": "^8.0.0",
43
- "tsx": "^4.7.0",
44
- "typescript": "^5.3.3"
45
- },
46
- "engines": {
47
- "node": ">=18"
48
- }
49
- }
69
+ }
package/README.md DELETED
@@ -1,134 +0,0 @@
1
- # @sneekin/sdk
2
-
3
- Node.js SDK for the **Sneek Send** API — deliver OTPs and transactional
4
- messages over SMS, WhatsApp, and email through Sneek's pre-approved DLT
5
- sender, WhatsApp BSP number, and warmed email domain.
6
-
7
- > Sneek **delivers**; your app generates and verifies its own OTP codes.
8
-
9
- ## Installation
10
-
11
- ```bash
12
- npm install @sneekin/sdk
13
- ```
14
-
15
- ## Quick start
16
-
17
- ```ts
18
- import { Sneek } from '@sneekin/sdk';
19
-
20
- const sneek = new Sneek({
21
- apiUrl: process.env.SNEEK_API_URL ?? 'https://api.sneek.in',
22
- apiKey: process.env.SNEEK_API_KEY!, // snk_live_… or snk_test_…
23
- });
24
-
25
- // Generic send (body OR template)
26
- await sneek.messages.send({
27
- to: '+919876543210',
28
- body: 'Your OTP to login to ConfHub is 482917. Do not share it.',
29
- channel: 'sms', // 'auto' | 'sms' | 'whatsapp' | 'email'
30
- });
31
-
32
- // Channel helpers
33
- await sneek.sendSMS('+919876543210', 'Your code is 482917');
34
- await sneek.sendWhatsApp('+919876543210', 'Your code is 482917');
35
- await sneek.sendEmail('user@example.com', 'Your code is 482917');
36
-
37
- // OTP body-formatting helper (you generate the code yourself)
38
- await sneek.sendOTP({
39
- to: '+919876543210',
40
- code: '482917',
41
- appName: 'ConfHub',
42
- channel: 'auto',
43
- });
44
-
45
- // DLT/provider templates (server-rendered)
46
- await sneek.messages.send({
47
- to: '+919876543210',
48
- channel: 'sms',
49
- template: 'otp_login',
50
- variables: { appName: 'ConfHub', otp: '482917' },
51
- });
52
- ```
53
-
54
- ### Message intent (`type`)
55
-
56
- Declare whether a message is an OTP or a plain communication so Sneek picks the
57
- correct approved provider template — instead of guessing from the body text:
58
-
59
- ```ts
60
- // Auth / OTP template
61
- await sneek.messages.send({ to, body: 'Your code is 482917', type: 'otp' });
62
-
63
- // Utility / communication template
64
- await sneek.messages.send({
65
- to,
66
- body: 'Your booking BK-7781 is confirmed.',
67
- type: 'transactional',
68
- });
69
- ```
70
-
71
- `type` accepts `'otp'` or `'transactional'`. When omitted, Sneek infers intent
72
- from the body (which can misclassify digit groups like `BK-7781` as an OTP), so
73
- **passing `type` explicitly is recommended.** `sendOTP(...)` always tags
74
- `type: 'otp'` for you.
75
-
76
- ## Authentication
77
-
78
- Every request is sent with `Authorization: Bearer <apiKey>`. Mint keys from the
79
- Sneek admin (`/o/<org>/applications/<id>` → API keys). Keys are shown **once**;
80
- store them in your environment / secret manager. `snk_test_…` keys hit dev
81
- adapters; `snk_live_…` keys deliver for real.
82
-
83
- ## Errors & retries
84
-
85
- - Non-2xx responses throw `SneekApiError` with `{ status, type, title, detail }`
86
- (RFC-7807 problem-details).
87
- - Transient `5xx` and network failures are retried with exponential backoff
88
- (`maxRetries`, default 2; `retryBaseMs`, default 200ms).
89
-
90
- ## Idempotency & rate limits
91
-
92
- Pass `idempotencyKey` to make a send safely repeatable — the first call is
93
- processed and its result is replayed for any retry with the same key (per
94
- application), so a dropped connection never sends twice. This pairs with the
95
- SDK's automatic `5xx` retries.
96
-
97
- ```ts
98
- await sneek.messages.send({
99
- to: '+919876543210',
100
- type: 'otp',
101
- body: 'Your code is 482917',
102
- idempotencyKey: 'order-4815-otp',
103
- });
104
- ```
105
-
106
- Sends are rate-limited per application (your plan's per-minute / per-day quota),
107
- per recipient, and per source IP. Exceeding a limit throws `SneekApiError` with
108
- `status === 429`; the problem body carries `retry_after` (seconds).
109
-
110
-
111
- ```ts
112
- import { SneekApiError } from '@sneekin/sdk';
113
-
114
- try {
115
- await sneek.sendSMS('+91…', 'hi');
116
- } catch (err) {
117
- if (err instanceof SneekApiError && err.status === 401) {
118
- // invalid / revoked key
119
- }
120
- }
121
- ```
122
-
123
- ## API
124
-
125
- | Method | Description |
126
- | --- | --- |
127
- | `messages.send(input)` | Send a message (body or template; optional `type` intent). Returns `202` accepted envelope. |
128
- | `messages.get(id)` | Fetch a previously-sent message by id. |
129
- | `sendSMS/sendWhatsApp/sendEmail(to, body, clientRef?)` | Channel-pinned helpers. |
130
- | `sendOTP({ to, code, appName?, channel?, template?, variables? })` | Format + send an OTP body. |
131
-
132
- ## License
133
-
134
- UNLICENSED — © Abblor Tech Pvt Ltd.