@zindua/sdk 1.2.3 → 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
@@ -1,261 +1,534 @@
1
1
  # @zindua/sdk
2
2
 
3
- Official **server-side** SDK for [Zindua](https://zindua.run): one API for transactional **email** and **WhatsApp** (`POST /api/v1/send`).
3
+ Official **server-side** SDK for [Zindua](https://zindua.run): send transactional **email** and **WhatsApp** messages with one API.
4
4
 
5
- | Resource | Link |
6
- |----------|------|
7
- | Website & docs | [zindua.run/developers](https://zindua.run/developers) |
8
- | HTTP / cURL (no SDK) | [zindua.run/developers#http](https://zindua.run/developers#http) |
9
- | Pricing & plans | [zindua.run/pricing](https://zindua.run/pricing) |
10
- | Dashboard | [zindua.run/login](https://zindua.run/login) |
5
+ | | |
6
+ |---|---|
7
+ | Full API reference | [zindua.run/developers](https://zindua.run/developers) |
8
+ | Dashboard (keys, templates, services) | [zindua.run/login](https://zindua.run/login) |
9
+ | Pricing | [zindua.run/pricing](https://zindua.run/pricing) |
11
10
 
12
- Install: `npm install @zindua/sdk`
11
+ ```bash
12
+ npm install @zindua/sdk
13
+ ```
14
+
15
+ **Requirements:** Node.js 18+, run only on your **backend** (not in the browser).
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+).
13
18
 
14
19
  ---
15
20
 
16
- ## Templates and languages
21
+ ## Quick start
22
+
23
+ ### 1. Create a Zindua project
17
24
 
18
- Create templates in the **dashboard** (one **slug** per template, e.g. `welcome`, `otp-verification`). Each template can include **email HTML** and **WhatsApp text** for the same slug.
25
+ 1. Sign in at [zindua.run/login](https://zindua.run/login).
26
+ 2. Open **Projects** → create a project (or use an existing one).
27
+ 3. Copy the API key (`znd_live_…` for production, `znd_test_…` for sandbox).
28
+ 4. In the project: connect **Service** (Gmail, SMTP, …) for email, and/or link **WhatsApp** for OTP.
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).
19
30
 
20
- Send in a locale with the `lang` field (ISO code: `fr`, `en`, `sw`, …). If you omit `lang`, Zindua uses your project default.
31
+ ### 2. Install and configure
21
32
 
22
- | Plan | Templates per project | Languages per template (email + WhatsApp) |
23
- |------|----------------------|-------------------------------------------|
24
- | **Free** | 5 | 3 |
25
- | **Pro** | 50 | 10 |
26
- | **Team** | 1000 | 100 |
33
+ ```bash
34
+ npm install @zindua/sdk
35
+ ```
27
36
 
28
- On **Free**, **email API** works when you connect a **Service** (Gmail, SMTP, etc.) on the project. **WhatsApp OTP** works on Free (quota applies). **Pro/Team** raise template and volume limits. See [pricing](https://zindua.run/pricing).
37
+ ```bash
38
+ # .env — never commit real keys; never expose in frontend code
39
+ ZINDUA_API_KEY=znd_live_xxxxxxxxxxxxxxxxxxxxxxxx
40
+ ```
29
41
 
30
42
  ```typescript
31
- // French version of the same template slug
43
+ import { Zindua } from "@zindua/sdk";
44
+
45
+ export const zindua = new Zindua({
46
+ apiKey: process.env.ZINDUA_API_KEY!,
47
+ });
48
+ ```
49
+
50
+ ### 3. Send a message
51
+
52
+ ```typescript
53
+ // Email (default channel)
32
54
  await zindua.send({
33
55
  to: "user@example.com",
34
56
  template: "welcome",
35
- lang: "fr",
36
57
  variables: { name: "Alex" },
37
58
  });
38
59
 
39
- // WhatsApp: same slug, different channel + E.164 phone
60
+ // WhatsApp E.164 phone with + (channel is required for WhatsApp)
40
61
  await zindua.send({
41
62
  to: "+243812345678",
42
63
  channel: "whatsapp",
43
- template: "otp-verification",
44
- lang: "fr",
45
- variables: { code: "4592", app: "MonApp" },
64
+ template: "otp",
65
+ variables: { code: "482910" },
46
66
  });
47
67
  ```
48
68
 
49
- ---
69
+ ### Channel and `to` must match
50
70
 
51
- ## Requirements
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.
52
72
 
53
- Before using the SDK, set up your Zindua project in the dashboard:
73
+ | `channel` | Valid `to` | Rejected (not sent) |
74
+ |-----------|------------|---------------------|
75
+ | `email` (default) | `user@example.com` | `+243812345678` |
76
+ | `whatsapp` | `+243812345678` | `user@example.com` |
54
77
 
55
- | Requirement | Required for | Where |
56
- |-------------|--------------|--------|
57
- | Zindua account | All | [Sign up / login](https://zindua.run/login) |
58
- | Project + API key (`znd_live_…` or `znd_test_…`) | All | Dashboard → **Projects** → your project |
59
- | Email **Service** connected (Gmail, Outlook, SendGrid, SMTP, …) | `channel: "email"` | Dashboard → **Service** |
60
- | WhatsApp connected (QR) | `channel: "whatsapp"` | Dashboard → **WhatsApp** |
61
- | Template slug (e.g. `welcome`, `otp-verification`) | All | Dashboard → **Templates** |
62
- | **Service** on Free or **Pro/Team** plan | Email via API when Service is connected (Free) or full quotas (paid) | Dashboard → **Service** / **Billing** |
78
+ ```typescript
79
+ // ❌ Phone with default channel email — rejected locally (INVALID_EMAIL)
80
+ await zindua.send({ to: "+243812345678", template: "otp", variables: { code: "1" } });
63
81
 
64
- **Runtime**
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
+ });
65
89
 
66
- - **Node.js 18+** (uses native `fetch`)
67
- - **Server only** — do not bundle this package for the browser (see [Mobile & SPA](#mobile--spa))
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
99
+ await zindua.send({
100
+ to: "+243812345678",
101
+ channel: "whatsapp",
102
+ template: "otp",
103
+ variables: { code: "482910" },
104
+ });
105
+ ```
68
106
 
69
107
  ---
70
108
 
71
- ## Install
109
+ ## Languages (multilingual projects)
72
110
 
73
- ```bash
74
- npm install @zindua/sdk
75
- ```
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.
76
112
 
77
- ```bash
78
- yarn add @zindua/sdk
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
79
140
  ```
80
141
 
81
- ```bash
82
- pnpm add @zindua/sdk
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
+ });
83
152
  ```
84
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
+
85
156
  ---
86
157
 
87
- ## Configure
158
+ ## Plans and channels
88
159
 
89
- ### 1. API key
160
+ | Plan | Email API | WhatsApp | Email / month | WhatsApp OTP / month |
161
+ |------|-----------|----------|---------------|----------------------|
162
+ | **Free** | Yes, if a **Service** is connected on the project | Yes | 25,000 | 200 |
163
+ | **Pro** | Yes | Yes | 160,000 | 20,000 |
164
+ | **Team** | Yes | Yes | High volume | Unlimited |
90
165
 
91
- Copy your project key from the dashboard. Format: `znd_live_` + 24 characters, or `znd_test_` for test mode.
166
+ On **Free**, email works through **your** connected provider (Gmail, SMTP, etc.). **Pro** and **Team** raise template counts and monthly limits. Details: [pricing](https://zindua.run/pricing).
92
167
 
93
- ```bash
94
- # .env (example — load with dotenv or your framework)
95
- ZINDUA_API_KEY=znd_live_xxxxxxxxxxxxxxxxxxxxxxxx
96
- ```
168
+ | | Free | Pro / Team |
169
+ |---|------|------------|
170
+ | Templates per project | 5 | 50+ |
171
+ | Languages per template | 3 | 10+ |
97
172
 
98
- The SDK does **not** read `ZINDUA_API_KEY` automatically. Pass it to the constructor explicitly.
173
+ Use `znd_test_…` keys to try the API without consuming live quota (sandbox behaviour).
99
174
 
100
- ### 2. Base URL (optional)
175
+ ---
101
176
 
102
- | Variable | Default | Notes |
103
- |----------|---------|--------|
104
- | `ZINDUA_API_BASE_URL` | `https://zindua.run/api/v1` | Used when `baseUrl` is omitted in constructor |
105
- | Constructor `baseUrl` | env or default | Overrides env; must be **HTTPS** in production (`http://localhost` allowed for local dev) |
177
+ ## One project or several?
106
178
 
107
- ### 3. Timeout (optional)
179
+ | Setup | When it fits |
180
+ |-------|----------------|
181
+ | **One Zindua project, one API key** | One product, one sender, shared templates and quota. |
182
+ | **Several Zindua projects, one key each** | Several clients or brands: separate templates, Gmail accounts, logs, and quotas. |
108
183
 
109
- | Option | Default | Max |
110
- |--------|---------|-----|
111
- | `timeoutMs` | `30000` | `120000` |
184
+ ### Several keys in one backend
112
185
 
113
- ### Minimal setup
186
+ ```bash
187
+ ZINDUA_KEY_CLIENT_A=znd_live_aaaaaaaaaaaaaaaaaaaaaaaa
188
+ ZINDUA_KEY_CLIENT_B=znd_live_bbbbbbbbbbbbbbbbbbbbbbbb
189
+ ```
114
190
 
115
191
  ```typescript
116
192
  import { Zindua } from "@zindua/sdk";
117
193
 
118
- export const zindua = new Zindua({
119
- apiKey: process.env.ZINDUA_API_KEY!,
120
- // baseUrl: process.env.ZINDUA_API_BASE_URL, // optional
121
- // timeoutMs: 30_000,
194
+ const clients = {
195
+ clientA: new Zindua({ apiKey: process.env.ZINDUA_KEY_CLIENT_A! }),
196
+ clientB: new Zindua({ apiKey: process.env.ZINDUA_KEY_CLIENT_B! }),
197
+ };
198
+
199
+ await clients.clientA.send({
200
+ to: "user@example.com",
201
+ template: "otp",
202
+ variables: { code: "123456" },
122
203
  });
123
204
  ```
124
205
 
206
+ Each key only accesses **its** Zindua project (templates, service, usage).
207
+
125
208
  ---
126
209
 
127
- ## Usage
210
+ ## Several templates in one application
128
211
 
129
- ### Send email
212
+ You do **not** need a separate SDK client per template. One `Zindua` instance, change the **`template` slug** and **`variables`** per send:
130
213
 
131
214
  ```typescript
215
+ // OTP
132
216
  await zindua.send({
133
217
  to: "user@example.com",
134
- template: "welcome",
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",
135
226
  variables: { name: "Alex" },
136
227
  });
137
228
  ```
138
229
 
139
- ### Send WhatsApp OTP
140
-
141
- Phone numbers must be **E.164** with a leading `+` (e.g. `+243812345678`).
230
+ Keep slugs in one place so your code stays clear:
142
231
 
143
232
  ```typescript
233
+ const Template = {
234
+ otp: "otp",
235
+ welcome: "user-welcome",
236
+ resetPassword: "password-reset",
237
+ } as const;
238
+
144
239
  await zindua.send({
145
- to: "+243812345678",
146
- channel: "whatsapp",
147
- template: "otp-verification",
148
- variables: { code: "4592", app: "MyApp" },
240
+ to: email,
241
+ template: Template.welcome,
242
+ variables: { name },
149
243
  });
150
244
  ```
151
245
 
152
- ### Optional fields (email only)
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
+
250
+ ## Framework examples
251
+
252
+ Use the same pattern everywhere: **your server** holds the API key; the client app calls **your** API.
253
+
254
+ ### Next.js (App Router)
153
255
 
154
256
  ```typescript
155
- await zindua.send({
156
- to: "user@example.com",
157
- template: "invoice",
158
- lang: "fr",
159
- cc: "billing@example.com",
160
- replyTo: "support@example.com",
161
- variables: { amount: "99" },
257
+ // lib/zindua.ts
258
+ import { Zindua } from "@zindua/sdk";
259
+
260
+ export const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
261
+ ```
262
+
263
+ ```typescript
264
+ // app/api/notify/route.ts
265
+ import { zindua } from "@/lib/zindua";
266
+ import { NextResponse } from "next/server";
267
+
268
+ export async function POST(req: Request) {
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
274
+ const result = await zindua.send({
275
+ to: contact,
276
+ channel,
277
+ template: "otp",
278
+ ...(lang ? { lang } : {}),
279
+ variables: { code },
280
+ });
281
+ return NextResponse.json(result, { status: 202 });
282
+ }
283
+ ```
284
+
285
+ ### Express
286
+
287
+ ```typescript
288
+ import express from "express";
289
+ import { Zindua } from "@zindua/sdk";
290
+
291
+ const app = express();
292
+ app.use(express.json());
293
+
294
+ const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
295
+
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
+ }
301
+ try {
302
+ const result = await zindua.send({
303
+ to: contact,
304
+ channel,
305
+ template: "otp",
306
+ variables: { code },
307
+ });
308
+ res.status(202).json(result);
309
+ } catch (err) {
310
+ res.status(500).json({ error: "Send failed" });
311
+ }
162
312
  });
163
313
  ```
164
314
 
165
- ### Response
315
+ ### Fastify
166
316
 
167
317
  ```typescript
168
- const result = await zindua.send({ /* … */ });
169
- // {
170
- // success: true,
171
- // status: "queued",
172
- // logId: "…",
173
- // channel: "email",
174
- // context: { project, apiKey: { mode, suffix }, plan, channels }
175
- // }
318
+ import Fastify from "fastify";
319
+ import { Zindua } from "@zindua/sdk";
320
+
321
+ const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
322
+ const app = Fastify();
323
+
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
+ );
176
340
  ```
177
341
 
178
- The API never returns your full API key. `context.apiKey.suffix` is the last 4 characters (match in Dashboard).
342
+ ### Hono
343
+
344
+ ```typescript
345
+ import { Hono } from "hono";
346
+ import { Zindua } from "@zindua/sdk";
347
+
348
+ const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
349
+ const app = new Hono();
350
+
351
+ app.post("/notify", async (c) => {
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
+ }
356
+ const result = await zindua.send({
357
+ to: contact,
358
+ channel,
359
+ template: "otp",
360
+ variables: { code },
361
+ });
362
+ return c.json(result, 202);
363
+ });
364
+ ```
365
+
366
+ ### Mobile apps (React Native, Flutter)
367
+
368
+ Do **not** embed `@zindua/sdk` or `znd_live_…` in the app. Call your backend route above from the device.
179
369
 
180
370
  ---
181
371
 
182
- ## Several clients (multi-project)
372
+ ## Configuration options
183
373
 
184
- **Recommended:** one Zindua project per client → one `znd_live_…` per project.
374
+ | Option | Default | Description |
375
+ |--------|---------|-------------|
376
+ | `apiKey` | — | **Required.** `znd_live_…` or `znd_test_…` from the dashboard. |
377
+ | `baseUrl` | `https://zindua.run/api/v1` | Override only for local testing (`http://localhost:3000/api/v1`). |
378
+ | `timeoutMs` | `30000` | Request timeout (max `120000`). |
185
379
 
186
- ```bash
187
- # .env server only
188
- ZINDUA_KEY_MELLIA=znd_live_xxxxxxxxxxxxxxxxxxxxxxxx
189
- ZINDUA_KEY_OVERLOOK=znd_live_yyyyyyyyyyyyyyyyyyyyyyyy
380
+ ```typescript
381
+ const zindua = new Zindua({
382
+ apiKey: process.env.ZINDUA_API_KEY!,
383
+ baseUrl: process.env.ZINDUA_API_BASE_URL, // optional
384
+ timeoutMs: 45_000,
385
+ });
190
386
  ```
191
387
 
192
- ```typescript
193
- import { Zindua } from "@zindua/sdk";
388
+ ---
194
389
 
195
- const zinduaByTenant = {
196
- mellia: new Zindua({ apiKey: process.env.ZINDUA_KEY_MELLIA! }),
197
- overlook: new Zindua({ apiKey: process.env.ZINDUA_KEY_OVERLOOK! }),
198
- };
390
+ ## Send options
391
+
392
+ | Field | Required | Description |
393
+ |-------|----------|-------------|
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. |
395
+ | `template` | Yes | Template slug from your dashboard. |
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`. |
398
+ | `variables` | No | `{{placeholders}}` in the template. |
399
+ | `cc`, `bcc`, `replyTo` | No | Email only. |
400
+
401
+ ---
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 |
199
415
 
200
- await zinduaByTenant[tenantId].send({
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({
201
420
  to: "user@example.com",
202
421
  template: "otp",
203
- variables: { code: "482910" },
422
+ variables: { code: "123456" },
204
423
  });
424
+
425
+ // Save logId — check delivery in the dashboard or via webhooks
426
+ console.log(result.logId, result.status, result.langUsed);
205
427
  ```
206
428
 
207
- | Approach | When to use |
208
- |----------|-------------|
209
- | **2+ Zindua projects** | Different clients, senders, templates, or quotas |
210
- | **1 Zindua project** | Same sender and templates for everyone (one key) |
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
+ ---
211
432
 
212
- More detail: [zindua.run/developers#multi-project](https://zindua.run/developers#multi-project) and repo `config/API-SECURITY-MULTI-PROJECT-FR.md`.
433
+ ## Successful response
434
+
435
+ ```typescript
436
+ const result = await zindua.send({ /* … */ });
437
+ ```
438
+
439
+ | Field | Meaning |
440
+ |-------|---------|
441
+ | `success` | `true` |
442
+ | `status` | `queued`, `processing`, or `sent` (test keys often return `sent` immediately). |
443
+ | `logId` | Id for support / logs in the dashboard. |
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 |
447
+ | `context` | Workspace snapshot (see below). |
448
+
449
+ ### `context` object
450
+
451
+ The API returns metadata so you can log or debug without exposing secrets:
452
+
453
+ ```json
454
+ {
455
+ "context": {
456
+ "project": { "name": "My App", "slug": "my-app" },
457
+ "apiKey": { "mode": "live", "suffix": "ejho" },
458
+ "plan": { "slug": "free", "name": "Free", "emailQuota": 25000, "emailsUsed": 12 },
459
+ "channels": { "email": true, "whatsapp": false }
460
+ }
461
+ }
462
+ ```
463
+
464
+ - **`context.apiKey.suffix`** — last 4 characters of the key (match in the dashboard).
465
+ - **`context.plan`** — current plan and usage for the workspace linked to this key.
466
+ - **`context.channels`** — whether email / WhatsApp is ready for this project.
467
+
468
+ The full API key is **never** returned in responses.
213
469
 
214
470
  ---
215
471
 
216
472
  ## Errors
217
473
 
474
+ Wrap sends in `try/catch` and handle `ZinduaError`:
475
+
218
476
  ```typescript
219
477
  import { Zindua, ZinduaError } from "@zindua/sdk";
220
478
 
221
479
  try {
222
- await zindua.send({ to: "+243…", channel: "whatsapp", template: "otp-verification" });
480
+ await zindua.send({ to: "user@example.com", template: "otp", variables: { code: "1" } });
223
481
  } catch (e) {
224
482
  if (e instanceof ZinduaError) {
225
- console.error(e.status, e.code, e.message);
226
- // Platform codes may include: WHATSAPP_NOT_CONNECTED, RATE_LIMIT_EXCEEDED, …
483
+ console.log(e.status); // HTTP status
484
+ console.log(e.code); // machine-readable code
485
+ console.log(e.message); // human-readable
486
+ console.log(e.details); // optional: hint, context, retryAfterSec, …
227
487
  }
228
- throw e;
229
488
  }
230
489
  ```
231
490
 
232
- `ZinduaError` never includes your API key in the message.
491
+ ### Error codes
492
+
493
+ | Code | HTTP | What it means | What to do |
494
+ |------|------|---------------|------------|
495
+ | `INVALID_API_KEY` | 401 | Missing or malformed key. | Use `Authorization: Bearer znd_live_…` (24 chars after prefix). |
496
+ | `API_KEY_NOT_FOUND` | 401 | Key not linked to a project. | Copy the key from Dashboard → your project. |
497
+ | `ORIGIN_NOT_ALLOWED` | 403 | Browser origin blocked. | Prefer server-side SDK; or add origin in Dashboard → Settings → Integrations. |
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. |
501
+ | `NO_SUBSCRIPTION` | 403 | Workspace has no active plan. | Open Dashboard → Billing or contact support. |
502
+ | `EMAIL_NOT_AVAILABLE` | 403 | Email not allowed on current plan setup. | Connect a Service on the project (Free), or upgrade plan. |
503
+ | `EMAIL_SERVICE_NOT_CONFIGURED` | 422 | No Gmail/SMTP connected. | Dashboard → project → **Service**. |
504
+ | `QUOTA_EXCEEDED` | 429 | Monthly email limit reached. | Upgrade plan or wait for reset. |
505
+ | `WHATSAPP_NOT_AVAILABLE` | 403 | WhatsApp not on plan. | Check [pricing](https://zindua.run/pricing). |
506
+ | `WHATSAPP_NOT_CONNECTED` | 422 | WhatsApp not linked. | Dashboard → project → **WhatsApp** → scan QR. |
507
+ | `WHATSAPP_PAUSED` | 422 | WhatsApp session paused. | Resume in the dashboard. |
508
+ | `WHATSAPP_QUOTA_EXCEEDED` | 429 | Monthly WhatsApp limit reached. | Upgrade or wait. |
509
+ | `RATE_LIMIT_EXCEEDED` | 429 | Sending too fast (WhatsApp). | Wait `retryAfterSec` from `e.details`, then retry. |
510
+ | `TEMPLATE_NOT_FOUND` | 404 | Unknown template slug. | Create template or fix slug; check `availableTemplateSlugs` in details. |
511
+ | `TEMPLATE_NO_CONTENT` | 404 | Template has no language version. | Add content in Dashboard → Templates. |
512
+ | `TEMPLATE_NO_WHATSAPP_BODY` | 422 | No plain text for WhatsApp. | Add a text body for that language. |
513
+ | `QUEUE_UNAVAILABLE` | 503 | Temporary delivery queue issue. | Retry shortly; contact support if it persists. |
514
+ | `BROWSER_FORBIDDEN` | — | SDK used in browser. | Move the call to your backend. |
515
+ | `REQUEST_TIMEOUT` | — | Request exceeded `timeoutMs`. | Increase timeout or retry. |
516
+
517
+ Many errors include a **`context`** field (same shape as success) with `plan` and `project` to help you debug.
233
518
 
234
519
  ---
235
520
 
236
- ## Security
237
-
238
- - **Server only** — throws in browser environments (`BROWSER_FORBIDDEN`).
239
- - **Bearer header only** — `Authorization: Bearer znd_live_…`. Keys in URL, JSON body, or `X-Api-Key` are rejected by the platform API.
240
- - **HTTPS** for `baseUrl` except `http://localhost` in development.
241
- - **No credentials in URL** — rejects `baseUrl` with embedded username/password.
242
- - **Input validation** — E.164 phones, email format, template slug, variable size limits (aligned with the platform API).
243
- - **Redirects disabled** — `fetch` uses `redirect: "error"`.
244
- - **CORS** — optional origin allowlist in Dashboard → Settings → Integrations (browser calls only).
245
-
246
- ---
247
-
248
- ## Mobile & SPA
249
-
250
- Never put `znd_live_` keys in React Native, Flutter, or browser code. Call your own backend; your backend uses this SDK.
521
+ ## Best practices
251
522
 
252
- See [Developer docs Quickstart](https://zindua.run/developers#setup).
523
+ 1. Store the API key in environment variables or a secrets manager.
524
+ 2. Call Zindua from your **API routes** only, not from React/Vue/mobile bundles.
525
+ 3. Use **`znd_test_…`** in staging; **`znd_live_…`** in production.
526
+ 4. Pin the SDK version in `package.json`, e.g. `"@zindua/sdk": "1.2.6"`.
527
+ 5. One Zindua project per client when quotas, senders, or templates must stay isolated.
253
528
 
254
529
  ---
255
530
 
256
- ## HTTP without the SDK
257
-
258
- Any language can call the same endpoint:
531
+ ## Without the SDK (HTTP)
259
532
 
260
533
  ```bash
261
534
  curl -X POST https://zindua.run/api/v1/send \
@@ -264,34 +537,20 @@ curl -X POST https://zindua.run/api/v1/send \
264
537
  -d '{"to":"user@example.com","template":"welcome","variables":{"name":"Alex"}}'
265
538
  ```
266
539
 
267
- Full reference: [HTTP / cURL](https://zindua.run/developers#http).
540
+ See [HTTP / cURL](https://zindua.run/developers#http).
268
541
 
269
542
  ---
270
543
 
271
- ## API surface
544
+ ## API reference
272
545
 
273
546
  | Export | Description |
274
547
  |--------|-------------|
275
548
  | `Zindua` | Client class |
276
- | `ZinduaError` | Typed error |
549
+ | `ZinduaError` | Error type (`status`, `code`, `message`, `details`) |
550
+ | `ZinduaSendResult` | Success payload type |
551
+ | `ZinduaSendContext` | Type of `result.context` |
277
552
  | `DEFAULT_API_BASE` | `https://zindua.run/api/v1` |
278
- | `LIMITS` | Validation limits (documented constants) |
279
-
280
- ---
281
-
282
- ## Local development (monorepo)
283
-
284
- ```bash
285
- cd packages/zindua-js
286
- npm run build
287
- npm test
288
- ```
289
-
290
- From the app repo root:
291
-
292
- ```bash
293
- npm install file:./packages/zindua-js
294
- ```
553
+ | `LIMITS` | Client-side validation limits |
295
554
 
296
555
  ---
297
556
 
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.3";
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.3",
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",