@zindua/sdk 1.2.4 → 1.2.6

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 CHANGED
@@ -14,6 +14,8 @@ npm install @zindua/sdk
14
14
 
15
15
  **Requirements:** Node.js 18+, run only on your **backend** (not in the browser).
16
16
 
17
+ **Dependencies:** `@zindua/sdk` has **no runtime npm dependencies**. After `npm install`, you are done — no second package to add. The client uses Node’s built-in `fetch` (Node 18+).
18
+
17
19
  ---
18
20
 
19
21
  ## Quick start
@@ -24,7 +26,7 @@ npm install @zindua/sdk
24
26
  2. Open **Projects** → create a project (or use an existing one).
25
27
  3. Copy the API key (`znd_live_…` for production, `znd_test_…` for sandbox).
26
28
  4. In the project: connect **Service** (Gmail, SMTP, …) for email, and/or link **WhatsApp** for OTP.
27
- 5. Create at least one **template** (e.g. slug `otp`, `welcome`).
29
+ 5. Create at least one **template** (e.g. slug `otp`, `welcome`). For several languages, add one version per language in Dashboard → Templates (and set the project **default language** in project settings).
28
30
 
29
31
  ### 2. Install and configure
30
32
 
@@ -55,7 +57,45 @@ await zindua.send({
55
57
  variables: { name: "Alex" },
56
58
  });
57
59
 
58
- // WhatsApp — phone in E.164 with +
60
+ // WhatsApp — E.164 phone with + (channel is required for WhatsApp)
61
+ await zindua.send({
62
+ to: "+243812345678",
63
+ channel: "whatsapp",
64
+ template: "otp",
65
+ variables: { code: "482910" },
66
+ });
67
+ ```
68
+
69
+ ### Channel and `to` must match
70
+
71
+ The SDK checks **before** calling the API (same rules as [zindua.run](https://zindua.run)). A phone number cannot be sent as email, and an email cannot be sent on WhatsApp.
72
+
73
+ | `channel` | Valid `to` | Rejected (not sent) |
74
+ |-----------|------------|---------------------|
75
+ | `email` (default) | `user@example.com` | `+243812345678` |
76
+ | `whatsapp` | `+243812345678` | `user@example.com` |
77
+
78
+ ```typescript
79
+ // ❌ Phone with default channel email — rejected locally (INVALID_EMAIL)
80
+ await zindua.send({ to: "+243812345678", template: "otp", variables: { code: "1" } });
81
+
82
+ // ❌ Email with WhatsApp channel — rejected locally (INVALID_PHONE)
83
+ await zindua.send({
84
+ to: "user@example.com",
85
+ channel: "whatsapp",
86
+ template: "otp",
87
+ variables: { code: "1" },
88
+ });
89
+
90
+ // ✅ User chose email for OTP
91
+ await zindua.send({
92
+ to: "user@example.com",
93
+ channel: "email",
94
+ template: "otp",
95
+ variables: { code: "482910" },
96
+ });
97
+
98
+ // ✅ User chose WhatsApp for OTP
59
99
  await zindua.send({
60
100
  to: "+243812345678",
61
101
  channel: "whatsapp",
@@ -66,6 +106,55 @@ await zindua.send({
66
106
 
67
107
  ---
68
108
 
109
+ ## Languages (multilingual projects)
110
+
111
+ When you create a project, you choose a **default language** (Dashboard → project settings). Each template can have **several language versions** (French, English, Swahili, …) with its own subject and body.
112
+
113
+ When you call `send()`, pass **`lang` only if you want a specific version**. If you omit it, Zindua uses the **project default**. If the language you ask for does not exist on that template, Zindua uses the template’s **default language** and sets `langFallback: true` in the response.
114
+
115
+ | What you send | What Zindua uses |
116
+ |---------------|------------------|
117
+ | No `lang` | Project default (e.g. `fr`) |
118
+ | `lang: "en"` and English exists on the template | English version |
119
+ | `lang: "de"` but only `fr` / `en` exist | Default language for that template + `langFallback: true` |
120
+
121
+ ```typescript
122
+ // Default language of the project
123
+ await zindua.send({
124
+ to: "user@example.com",
125
+ template: "otp",
126
+ variables: { code: "482910" },
127
+ });
128
+
129
+ // Explicit language (email or WhatsApp — same `lang` for both channels)
130
+ const result = await zindua.send({
131
+ to: "+243812345678",
132
+ channel: "whatsapp",
133
+ template: "otp",
134
+ lang: "fr",
135
+ variables: { code: "482910" },
136
+ });
137
+
138
+ console.log(result.langUsed); // e.g. "fr" — language actually rendered
139
+ console.log(result.langFallback); // true if `lang` was missing and fallback was used
140
+ ```
141
+
142
+ OTP with the user’s locale (optional):
143
+
144
+ ```typescript
145
+ await zindua.send({
146
+ to: contact,
147
+ channel,
148
+ template: "otp",
149
+ lang: userLocale, // e.g. "fr", "en" — omit if you want project default
150
+ variables: { code },
151
+ });
152
+ ```
153
+
154
+ Allowed codes: short ISO 639-1 style (`fr`, `en`, `sw`, `en-us`). Invalid format is rejected by the SDK before the HTTP call.
155
+
156
+ ---
157
+
69
158
  ## Plans and channels
70
159
 
71
160
  | Plan | Email API | WhatsApp | Email / month | WhatsApp OTP / month |
@@ -118,6 +207,46 @@ Each key only accesses **its** Zindua project (templates, service, usage).
118
207
 
119
208
  ---
120
209
 
210
+ ## Several templates in one application
211
+
212
+ You do **not** need a separate SDK client per template. One `Zindua` instance, change the **`template` slug** and **`variables`** per send:
213
+
214
+ ```typescript
215
+ // OTP
216
+ await zindua.send({
217
+ to: "user@example.com",
218
+ template: "otp",
219
+ variables: { code: "482910" },
220
+ });
221
+
222
+ // Welcome email — different slug, different variables
223
+ await zindua.send({
224
+ to: "user@example.com",
225
+ template: "user-welcome",
226
+ variables: { name: "Alex" },
227
+ });
228
+ ```
229
+
230
+ Keep slugs in one place so your code stays clear:
231
+
232
+ ```typescript
233
+ const Template = {
234
+ otp: "otp",
235
+ welcome: "user-welcome",
236
+ resetPassword: "password-reset",
237
+ } as const;
238
+
239
+ await zindua.send({
240
+ to: email,
241
+ template: Template.welcome,
242
+ variables: { name },
243
+ });
244
+ ```
245
+
246
+ Each template defines its own `{{variables}}` in the dashboard. The SDK does not merge templates: one `send()` call = one template slug.
247
+
248
+ ---
249
+
121
250
  ## Framework examples
122
251
 
123
252
  Use the same pattern everywhere: **your server** holds the API key; the client app calls **your** API.
@@ -137,10 +266,16 @@ import { zindua } from "@/lib/zindua";
137
266
  import { NextResponse } from "next/server";
138
267
 
139
268
  export async function POST(req: Request) {
140
- const { email, code } = await req.json();
269
+ const { channel, contact, code, lang } = await req.json();
270
+ if (channel !== "email" && channel !== "whatsapp") {
271
+ return NextResponse.json({ error: "channel must be email or whatsapp" }, { status: 400 });
272
+ }
273
+ // contact = email string OR +243… phone, depending on channel
141
274
  const result = await zindua.send({
142
- to: email,
275
+ to: contact,
276
+ channel,
143
277
  template: "otp",
278
+ ...(lang ? { lang } : {}),
144
279
  variables: { code },
145
280
  });
146
281
  return NextResponse.json(result, { status: 202 });
@@ -159,11 +294,16 @@ app.use(express.json());
159
294
  const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
160
295
 
161
296
  app.post("/api/notify", async (req, res) => {
297
+ const { channel, contact, code } = req.body;
298
+ if (channel !== "email" && channel !== "whatsapp") {
299
+ return res.status(400).json({ error: "channel must be email or whatsapp" });
300
+ }
162
301
  try {
163
302
  const result = await zindua.send({
164
- to: req.body.email,
165
- template: req.body.template ?? "otp",
166
- variables: req.body.variables ?? {},
303
+ to: contact,
304
+ channel,
305
+ template: "otp",
306
+ variables: { code },
167
307
  });
168
308
  res.status(202).json(result);
169
309
  } catch (err) {
@@ -181,14 +321,22 @@ import { Zindua } from "@zindua/sdk";
181
321
  const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
182
322
  const app = Fastify();
183
323
 
184
- app.post<{ Body: { email: string; code: string } }>("/notify", async (req, reply) => {
185
- const result = await zindua.send({
186
- to: req.body.email,
187
- template: "otp",
188
- variables: { code: req.body.code },
189
- });
190
- return reply.status(202).send(result);
191
- });
324
+ app.post<{ Body: { channel: "email" | "whatsapp"; contact: string; code: string } }>(
325
+ "/notify",
326
+ async (req, reply) => {
327
+ const { channel, contact, code } = req.body;
328
+ if (channel !== "email" && channel !== "whatsapp") {
329
+ return reply.status(400).send({ error: "channel must be email or whatsapp" });
330
+ }
331
+ const result = await zindua.send({
332
+ to: contact,
333
+ channel,
334
+ template: "otp",
335
+ variables: { code },
336
+ });
337
+ return reply.status(202).send(result);
338
+ }
339
+ );
192
340
  ```
193
341
 
194
342
  ### Hono
@@ -201,9 +349,13 @@ const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
201
349
  const app = new Hono();
202
350
 
203
351
  app.post("/notify", async (c) => {
204
- const { email, code } = await c.req.json();
352
+ const { channel, contact, code } = await c.req.json();
353
+ if (channel !== "email" && channel !== "whatsapp") {
354
+ return c.json({ error: "channel must be email or whatsapp" }, 400);
355
+ }
205
356
  const result = await zindua.send({
206
- to: email,
357
+ to: contact,
358
+ channel,
207
359
  template: "otp",
208
360
  variables: { code },
209
361
  });
@@ -239,15 +391,45 @@ const zindua = new Zindua({
239
391
 
240
392
  | Field | Required | Description |
241
393
  |-------|----------|-------------|
242
- | `to` | Yes | Email address, or E.164 phone (`+243…`) for WhatsApp. |
394
+ | `to` | Yes | **Email** if `channel` is `email` (default). **E.164 phone** (`+243…`) if `channel` is `whatsapp`. Mismatches are rejected by the SDK and by the API. |
243
395
  | `template` | Yes | Template slug from your dashboard. |
244
- | `channel` | No | `"email"` (default) or `"whatsapp"`. |
245
- | `lang` | No | `fr`, `en`, … — falls back to project default. |
396
+ | `channel` | No | `"email"` (default) or `"whatsapp"`. Must match the format of `to`. |
397
+ | `lang` | No | Optional. `fr`, `en`, `sw`, … — if omitted, project default; if missing on template, fallback + `langFallback: true`. |
246
398
  | `variables` | No | `{{placeholders}}` in the template. |
247
399
  | `cc`, `bcc`, `replyTo` | No | Email only. |
248
400
 
249
401
  ---
250
402
 
403
+ ## API key, recipient, and delivery — what is checked when
404
+
405
+ | Situation | When you know | What to do |
406
+ |-----------|---------------|------------|
407
+ | Wrong or missing API key | Immediately (`401`, `INVALID_API_KEY` / `API_KEY_NOT_FOUND`) | Copy key from Dashboard → Projects |
408
+ | Phone sent as email (or the opposite) | Immediately in the SDK (`INVALID_EMAIL` / `INVALID_PHONE`, HTTP status `0`) | Match `channel` and `to` (see above) |
409
+ | Malformed email or phone | Immediately (SDK + API `400`) | Fix format: `user@domain.com`, `+243812345678` |
410
+ | Typo in email (`user@gmial.com`) | **Not** blocked at send time | Request may succeed (`202`); provider may bounce later |
411
+ | Email inbox does not exist | **Not** verified upfront | Check **Dashboard → Logs** with `logId`; configure **webhooks** (`email.delivered` / `email.failed`) |
412
+ | Phone valid in E.164 but **no WhatsApp** on that number | **Not** verified upfront | WhatsApp send can fail after accept; status `failed` in logs |
413
+ | Template slug wrong | API `404` `TEMPLATE_NOT_FOUND` | Create template or fix slug |
414
+ | No Gmail/SMTP / WhatsApp not linked | API `422` | Connect **Service** or **WhatsApp** on the project |
415
+
416
+ **Important:** a successful `send()` returns `logId` and often `status: "queued"`. That means Zindua accepted the message and will deliver through **your** connected email service or WhatsApp session. It does not guarantee the address exists or that WhatsApp is installed on that number.
417
+
418
+ ```typescript
419
+ const result = await zindua.send({
420
+ to: "user@example.com",
421
+ template: "otp",
422
+ variables: { code: "123456" },
423
+ });
424
+
425
+ // Save logId — check delivery in the dashboard or via webhooks
426
+ console.log(result.logId, result.status, result.langUsed);
427
+ ```
428
+
429
+ Optional in **your** app (before calling Zindua): stricter email regex, confirmation field, or a phone library — the SDK already enforces platform format and channel rules.
430
+
431
+ ---
432
+
251
433
  ## Successful response
252
434
 
253
435
  ```typescript
@@ -260,6 +442,8 @@ const result = await zindua.send({ /* … */ });
260
442
  | `status` | `queued`, `processing`, or `sent` (test keys often return `sent` immediately). |
261
443
  | `logId` | Id for support / logs in the dashboard. |
262
444
  | `channel` | `email` or `whatsapp` |
445
+ | `langUsed` | Language version used for this send |
446
+ | `langFallback` | `true` if requested `lang` was missing and a fallback version was used |
263
447
  | `context` | Workspace snapshot (see below). |
264
448
 
265
449
  ### `context` object
@@ -311,9 +495,9 @@ try {
311
495
  | `INVALID_API_KEY` | 401 | Missing or malformed key. | Use `Authorization: Bearer znd_live_…` (24 chars after prefix). |
312
496
  | `API_KEY_NOT_FOUND` | 401 | Key not linked to a project. | Copy the key from Dashboard → your project. |
313
497
  | `ORIGIN_NOT_ALLOWED` | 403 | Browser origin blocked. | Prefer server-side SDK; or add origin in Dashboard → Settings → Integrations. |
314
- | `MISSING_FIELDS` | 400 | `to` or `template` missing. | Send both in the request body. |
315
- | `INVALID_EMAIL` | 400 | Bad email format. | Use `user@domain.com`. |
316
- | `INVALID_PHONE` | 400 | Bad WhatsApp number. | Use E.164 with `+`, e.g. `+243812345678`. |
498
+ | `MISSING_FIELDS` | 400 / 0 | `to` or `template` missing. | Send both. Status `0` = caught by the SDK before HTTP. |
499
+ | `INVALID_EMAIL` | 400 / 0 | Bad email, or **phone used with channel email**. | Use `user@domain.com`, or set `channel: "whatsapp"` for `+243…`. |
500
+ | `INVALID_PHONE` | 400 / 0 | Bad WhatsApp number, or **email used with channel whatsapp**. | Use `+243812345678`, or set `channel: "email"` for an address. |
317
501
  | `NO_SUBSCRIPTION` | 403 | Workspace has no active plan. | Open Dashboard → Billing or contact support. |
318
502
  | `EMAIL_NOT_AVAILABLE` | 403 | Email not allowed on current plan setup. | Connect a Service on the project (Free), or upgrade plan. |
319
503
  | `EMAIL_SERVICE_NOT_CONFIGURED` | 422 | No Gmail/SMTP connected. | Dashboard → project → **Service**. |
@@ -339,7 +523,7 @@ Many errors include a **`context`** field (same shape as success) with `plan` an
339
523
  1. Store the API key in environment variables or a secrets manager.
340
524
  2. Call Zindua from your **API routes** only, not from React/Vue/mobile bundles.
341
525
  3. Use **`znd_test_…`** in staging; **`znd_live_…`** in production.
342
- 4. Pin the SDK version in `package.json`, e.g. `"@zindua/sdk": "1.2.4"`.
526
+ 4. Pin the SDK version in `package.json`, e.g. `"@zindua/sdk": "1.2.6"`.
343
527
  5. One Zindua project per client when quotas, senders, or templates must stay isolated.
344
528
 
345
529
  ---
package/dist/client.js CHANGED
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Zindua = void 0;
4
4
  const errors_1 = require("./errors");
5
5
  const validate_1 = require("./validate");
6
- const SDK_VERSION = "1.2.4";
6
+ const SDK_VERSION = "1.2.6";
7
7
  const USER_AGENT = `Zindua-JS/${SDK_VERSION}`;
8
8
  function buildPayload(options, channel) {
9
9
  const to = (0, validate_1.validateRecipient)(options.to, channel);
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ZinduaErrorCode = "INVALID_API_KEY" | "INVALID_BASE_URL" | "BROWSER_FORBIDDEN" | "INVALID_OPTIONS" | "INVALID_RECIPIENT" | "INVALID_TEMPLATE" | "INVALID_CHANNEL" | "INVALID_LANG" | "INVALID_VARIABLES" | "REQUEST_TIMEOUT" | "INVALID_RESPONSE" | "API_ERROR";
1
+ export type ZinduaErrorCode = "INVALID_API_KEY" | "INVALID_BASE_URL" | "BROWSER_FORBIDDEN" | "INVALID_OPTIONS" | "INVALID_EMAIL" | "INVALID_PHONE" | "MISSING_FIELDS" | "INVALID_TEMPLATE" | "INVALID_CHANNEL" | "INVALID_LANG" | "INVALID_VARIABLES" | "REQUEST_TIMEOUT" | "INVALID_RESPONSE" | "API_ERROR";
2
2
  /** Structured error — never includes the API key in the message. */
3
3
  export declare class ZinduaError extends Error {
4
4
  readonly status: number;
package/dist/validate.js CHANGED
@@ -107,20 +107,26 @@ function validateRecipient(to, channel) {
107
107
  if (!recipient || recipient.length > exports.LIMITS.maxToLength) {
108
108
  throw new errors_1.ZinduaError("to is required and must be under 320 characters.", {
109
109
  status: 0,
110
- code: "INVALID_RECIPIENT",
110
+ code: "MISSING_FIELDS",
111
111
  });
112
112
  }
113
113
  if (channel === "email") {
114
+ if (E164_RE.test(recipient)) {
115
+ throw new errors_1.ZinduaError('A phone number cannot be sent on channel "email". Use an email address (e.g. user@example.com) or set channel to "whatsapp".', { status: 0, code: "INVALID_EMAIL" });
116
+ }
114
117
  if (!EMAIL_RE.test(recipient)) {
115
118
  throw new errors_1.ZinduaError("Invalid email address for channel email.", {
116
119
  status: 0,
117
- code: "INVALID_RECIPIENT",
120
+ code: "INVALID_EMAIL",
118
121
  });
119
122
  }
120
123
  return recipient;
121
124
  }
125
+ if (EMAIL_RE.test(recipient)) {
126
+ throw new errors_1.ZinduaError('An email address cannot be sent on channel "whatsapp". Use E.164 with + (e.g. +243812345678) or set channel to "email".', { status: 0, code: "INVALID_PHONE" });
127
+ }
122
128
  if (!E164_RE.test(recipient)) {
123
- throw new errors_1.ZinduaError("WhatsApp to must be E.164 with leading + (e.g. +243812345678).", { status: 0, code: "INVALID_RECIPIENT" });
129
+ throw new errors_1.ZinduaError("WhatsApp to must be E.164 with leading + (e.g. +243812345678).", { status: 0, code: "INVALID_PHONE" });
124
130
  }
125
131
  return recipient;
126
132
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zindua/sdk",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
4
4
  "description": "Official Zindua SDK for Node.js — transactional email and WhatsApp via POST /api/v1/send.",
5
5
  "author": "Zindua",
6
6
  "license": "MIT",