@zindua/sdk 1.2.4 → 1.2.7
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 +236 -24
- package/dist/client.d.ts +51 -1
- package/dist/client.js +91 -32
- package/dist/errors.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +28 -3
- package/package.json +1 -1
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 —
|
|
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 {
|
|
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:
|
|
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:
|
|
165
|
-
|
|
166
|
-
|
|
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 } }>(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 {
|
|
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:
|
|
357
|
+
to: contact,
|
|
358
|
+
channel,
|
|
207
359
|
template: "otp",
|
|
208
360
|
variables: { code },
|
|
209
361
|
});
|
|
@@ -217,6 +369,32 @@ Do **not** embed `@zindua/sdk` or `znd_live_…` in the app. Call your backend r
|
|
|
217
369
|
|
|
218
370
|
---
|
|
219
371
|
|
|
372
|
+
## WordPress site binding & `connect()`
|
|
373
|
+
|
|
374
|
+
Each API key can be linked to **one site URL** (used by the [WordPress plugin](https://zindua.run/wordpress)). Pass `siteUrl` on the client so every request includes `X-Zindua-Site-Url`:
|
|
375
|
+
|
|
376
|
+
```typescript
|
|
377
|
+
const zindua = new Zindua({
|
|
378
|
+
apiKey: process.env.ZINDUA_API_KEY!,
|
|
379
|
+
siteUrl: "https://shop.example.com",
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// First connect binds the key to this URL
|
|
383
|
+
const status = await zindua.connect();
|
|
384
|
+
console.log(status.project.name, status.templates);
|
|
385
|
+
|
|
386
|
+
// Later: list templates with langs (fr, en, …)
|
|
387
|
+
const { templates } = await zindua.getTemplates();
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
| Error code | Meaning |
|
|
391
|
+
|------------|---------|
|
|
392
|
+
| `SITE_ALREADY_BOUND` | Key already used on another site — create a new project or regenerate the key |
|
|
393
|
+
| `SITE_MISMATCH` | `siteUrl` does not match the registered site |
|
|
394
|
+
| `SITE_URL_REQUIRED` | Key is bound; update the SDK and set `siteUrl` |
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
220
398
|
## Configuration options
|
|
221
399
|
|
|
222
400
|
| Option | Default | Description |
|
|
@@ -224,10 +402,12 @@ Do **not** embed `@zindua/sdk` or `znd_live_…` in the app. Call your backend r
|
|
|
224
402
|
| `apiKey` | — | **Required.** `znd_live_…` or `znd_test_…` from the dashboard. |
|
|
225
403
|
| `baseUrl` | `https://zindua.run/api/v1` | Override only for local testing (`http://localhost:3000/api/v1`). |
|
|
226
404
|
| `timeoutMs` | `30000` | Request timeout (max `120000`). |
|
|
405
|
+
| `siteUrl` | — | Your app origin (`https://example.com`). Required once the key is site-bound. |
|
|
227
406
|
|
|
228
407
|
```typescript
|
|
229
408
|
const zindua = new Zindua({
|
|
230
409
|
apiKey: process.env.ZINDUA_API_KEY!,
|
|
410
|
+
siteUrl: process.env.APP_URL,
|
|
231
411
|
baseUrl: process.env.ZINDUA_API_BASE_URL, // optional
|
|
232
412
|
timeoutMs: 45_000,
|
|
233
413
|
});
|
|
@@ -239,15 +419,45 @@ const zindua = new Zindua({
|
|
|
239
419
|
|
|
240
420
|
| Field | Required | Description |
|
|
241
421
|
|-------|----------|-------------|
|
|
242
|
-
| `to` | Yes | Email
|
|
422
|
+
| `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
423
|
| `template` | Yes | Template slug from your dashboard. |
|
|
244
|
-
| `channel` | No | `"email"` (default) or `"whatsapp"`. |
|
|
245
|
-
| `lang` | No | `fr`, `en`, … —
|
|
424
|
+
| `channel` | No | `"email"` (default) or `"whatsapp"`. Must match the format of `to`. |
|
|
425
|
+
| `lang` | No | Optional. `fr`, `en`, `sw`, … — if omitted, project default; if missing on template, fallback + `langFallback: true`. |
|
|
246
426
|
| `variables` | No | `{{placeholders}}` in the template. |
|
|
247
427
|
| `cc`, `bcc`, `replyTo` | No | Email only. |
|
|
248
428
|
|
|
249
429
|
---
|
|
250
430
|
|
|
431
|
+
## API key, recipient, and delivery — what is checked when
|
|
432
|
+
|
|
433
|
+
| Situation | When you know | What to do |
|
|
434
|
+
|-----------|---------------|------------|
|
|
435
|
+
| Wrong or missing API key | Immediately (`401`, `INVALID_API_KEY` / `API_KEY_NOT_FOUND`) | Copy key from Dashboard → Projects |
|
|
436
|
+
| Phone sent as email (or the opposite) | Immediately in the SDK (`INVALID_EMAIL` / `INVALID_PHONE`, HTTP status `0`) | Match `channel` and `to` (see above) |
|
|
437
|
+
| Malformed email or phone | Immediately (SDK + API `400`) | Fix format: `user@domain.com`, `+243812345678` |
|
|
438
|
+
| Typo in email (`user@gmial.com`) | **Not** blocked at send time | Request may succeed (`202`); provider may bounce later |
|
|
439
|
+
| Email inbox does not exist | **Not** verified upfront | Check **Dashboard → Logs** with `logId`; configure **webhooks** (`email.delivered` / `email.failed`) |
|
|
440
|
+
| Phone valid in E.164 but **no WhatsApp** on that number | **Not** verified upfront | WhatsApp send can fail after accept; status `failed` in logs |
|
|
441
|
+
| Template slug wrong | API `404` `TEMPLATE_NOT_FOUND` | Create template or fix slug |
|
|
442
|
+
| No Gmail/SMTP / WhatsApp not linked | API `422` | Connect **Service** or **WhatsApp** on the project |
|
|
443
|
+
|
|
444
|
+
**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.
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
const result = await zindua.send({
|
|
448
|
+
to: "user@example.com",
|
|
449
|
+
template: "otp",
|
|
450
|
+
variables: { code: "123456" },
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Save logId — check delivery in the dashboard or via webhooks
|
|
454
|
+
console.log(result.logId, result.status, result.langUsed);
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
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.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
251
461
|
## Successful response
|
|
252
462
|
|
|
253
463
|
```typescript
|
|
@@ -260,6 +470,8 @@ const result = await zindua.send({ /* … */ });
|
|
|
260
470
|
| `status` | `queued`, `processing`, or `sent` (test keys often return `sent` immediately). |
|
|
261
471
|
| `logId` | Id for support / logs in the dashboard. |
|
|
262
472
|
| `channel` | `email` or `whatsapp` |
|
|
473
|
+
| `langUsed` | Language version used for this send |
|
|
474
|
+
| `langFallback` | `true` if requested `lang` was missing and a fallback version was used |
|
|
263
475
|
| `context` | Workspace snapshot (see below). |
|
|
264
476
|
|
|
265
477
|
### `context` object
|
|
@@ -311,9 +523,9 @@ try {
|
|
|
311
523
|
| `INVALID_API_KEY` | 401 | Missing or malformed key. | Use `Authorization: Bearer znd_live_…` (24 chars after prefix). |
|
|
312
524
|
| `API_KEY_NOT_FOUND` | 401 | Key not linked to a project. | Copy the key from Dashboard → your project. |
|
|
313
525
|
| `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
|
|
315
|
-
| `INVALID_EMAIL` | 400 | Bad email
|
|
316
|
-
| `INVALID_PHONE` | 400 | Bad WhatsApp number
|
|
526
|
+
| `MISSING_FIELDS` | 400 / 0 | `to` or `template` missing. | Send both. Status `0` = caught by the SDK before HTTP. |
|
|
527
|
+
| `INVALID_EMAIL` | 400 / 0 | Bad email, or **phone used with channel email**. | Use `user@domain.com`, or set `channel: "whatsapp"` for `+243…`. |
|
|
528
|
+
| `INVALID_PHONE` | 400 / 0 | Bad WhatsApp number, or **email used with channel whatsapp**. | Use `+243812345678`, or set `channel: "email"` for an address. |
|
|
317
529
|
| `NO_SUBSCRIPTION` | 403 | Workspace has no active plan. | Open Dashboard → Billing or contact support. |
|
|
318
530
|
| `EMAIL_NOT_AVAILABLE` | 403 | Email not allowed on current plan setup. | Connect a Service on the project (Free), or upgrade plan. |
|
|
319
531
|
| `EMAIL_SERVICE_NOT_CONFIGURED` | 422 | No Gmail/SMTP connected. | Dashboard → project → **Service**. |
|
|
@@ -339,7 +551,7 @@ Many errors include a **`context`** field (same shape as success) with `plan` an
|
|
|
339
551
|
1. Store the API key in environment variables or a secrets manager.
|
|
340
552
|
2. Call Zindua from your **API routes** only, not from React/Vue/mobile bundles.
|
|
341
553
|
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.
|
|
554
|
+
4. Pin the SDK version in `package.json`, e.g. `"@zindua/sdk": "1.2.6"`.
|
|
343
555
|
5. One Zindua project per client when quotas, senders, or templates must stay isolated.
|
|
344
556
|
|
|
345
557
|
---
|
package/dist/client.d.ts
CHANGED
|
@@ -16,6 +16,41 @@ export type ZinduaClientOptions = {
|
|
|
16
16
|
baseUrl?: string;
|
|
17
17
|
/** Request timeout (default 30s, max 120s). */
|
|
18
18
|
timeoutMs?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Your app or WordPress site origin (e.g. https://shop.example.com).
|
|
21
|
+
* Sent as X-Zindua-Site-Url — required once the API key is bound to a site.
|
|
22
|
+
*/
|
|
23
|
+
siteUrl?: string;
|
|
24
|
+
};
|
|
25
|
+
export type ZinduaProjectInfo = {
|
|
26
|
+
name: string;
|
|
27
|
+
slug: string;
|
|
28
|
+
defaultLang: string;
|
|
29
|
+
dashboardUrl: string;
|
|
30
|
+
};
|
|
31
|
+
export type ZinduaTemplateInfo = {
|
|
32
|
+
slug: string;
|
|
33
|
+
langs: string[];
|
|
34
|
+
defaultLang: string;
|
|
35
|
+
variables: string[];
|
|
36
|
+
};
|
|
37
|
+
export type ZinduaConnectResult = {
|
|
38
|
+
ok: true;
|
|
39
|
+
connected: true;
|
|
40
|
+
siteUrl: string;
|
|
41
|
+
newlyBound: boolean;
|
|
42
|
+
project: ZinduaProjectInfo;
|
|
43
|
+
templates: ZinduaTemplateInfo[];
|
|
44
|
+
plan: ZinduaSendContext["plan"];
|
|
45
|
+
channels: {
|
|
46
|
+
email: {
|
|
47
|
+
ready: boolean;
|
|
48
|
+
};
|
|
49
|
+
whatsapp: {
|
|
50
|
+
ready: boolean;
|
|
51
|
+
status?: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
19
54
|
};
|
|
20
55
|
export type ZinduaSendContext = {
|
|
21
56
|
project: {
|
|
@@ -58,10 +93,25 @@ export type ZinduaSendResult = {
|
|
|
58
93
|
};
|
|
59
94
|
export declare class Zindua {
|
|
60
95
|
private readonly apiKey;
|
|
61
|
-
private readonly
|
|
96
|
+
private readonly apiBase;
|
|
62
97
|
private readonly timeoutMs;
|
|
98
|
+
private readonly siteUrl?;
|
|
63
99
|
constructor(options: ZinduaClientOptions);
|
|
64
100
|
/** Returns true when using a znd_test_ key (sandbox / no real delivery). */
|
|
65
101
|
isTestMode(): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Bind this API key to your site (WordPress connect flow).
|
|
104
|
+
* First call wins — the same key cannot be used on another site URL.
|
|
105
|
+
*/
|
|
106
|
+
connect(siteUrl?: string): Promise<ZinduaConnectResult>;
|
|
107
|
+
/** Project status, channels, and plan (GET /project). */
|
|
108
|
+
getProject(): Promise<Record<string, unknown>>;
|
|
109
|
+
/** Synced templates with langs and variables (GET /templates). */
|
|
110
|
+
getTemplates(): Promise<{
|
|
111
|
+
templates: ZinduaTemplateInfo[];
|
|
112
|
+
limits?: Record<string, unknown>;
|
|
113
|
+
}>;
|
|
66
114
|
send(options: ZinduaSendOptions): Promise<ZinduaSendResult>;
|
|
115
|
+
private buildHeaders;
|
|
116
|
+
private request;
|
|
67
117
|
}
|
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.
|
|
6
|
+
const SDK_VERSION = "1.2.7";
|
|
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);
|
|
@@ -50,6 +50,8 @@ function parseApiError(status, body) {
|
|
|
50
50
|
details.hint = body.hint;
|
|
51
51
|
if (typeof body.retryAfterSec === "number")
|
|
52
52
|
details.retryAfterSec = body.retryAfterSec;
|
|
53
|
+
if (typeof body.boundSite === "string")
|
|
54
|
+
details.boundSite = body.boundSite;
|
|
53
55
|
if (Array.isArray(body.availableTemplateSlugs)) {
|
|
54
56
|
details.availableTemplateSlugs = body.availableTemplateSlugs;
|
|
55
57
|
}
|
|
@@ -69,34 +71,109 @@ function parseApiError(status, body) {
|
|
|
69
71
|
}
|
|
70
72
|
class Zindua {
|
|
71
73
|
apiKey;
|
|
72
|
-
|
|
74
|
+
apiBase;
|
|
73
75
|
timeoutMs;
|
|
76
|
+
siteUrl;
|
|
74
77
|
constructor(options) {
|
|
75
78
|
(0, validate_1.assertServerRuntime)();
|
|
76
79
|
this.apiKey = (0, validate_1.validateApiKey)(options.apiKey);
|
|
77
|
-
|
|
78
|
-
this.sendUrl = `${base}/send`;
|
|
80
|
+
this.apiBase = (0, validate_1.resolveBaseUrl)(options.baseUrl);
|
|
79
81
|
this.timeoutMs = (0, validate_1.validateTimeoutMs)(options.timeoutMs);
|
|
82
|
+
this.siteUrl = (0, validate_1.validateSiteUrl)(options.siteUrl);
|
|
80
83
|
}
|
|
81
84
|
/** Returns true when using a znd_test_ key (sandbox / no real delivery). */
|
|
82
85
|
isTestMode() {
|
|
83
86
|
return this.apiKey.startsWith("znd_test_");
|
|
84
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Bind this API key to your site (WordPress connect flow).
|
|
90
|
+
* First call wins — the same key cannot be used on another site URL.
|
|
91
|
+
*/
|
|
92
|
+
async connect(siteUrl) {
|
|
93
|
+
const url = (0, validate_1.validateSiteUrl)(siteUrl) ?? this.siteUrl;
|
|
94
|
+
const data = await this.request("POST", "connect", {
|
|
95
|
+
siteUrl: url,
|
|
96
|
+
});
|
|
97
|
+
if (data.ok !== true || !data.project || !Array.isArray(data.templates)) {
|
|
98
|
+
throw new errors_1.ZinduaError("API response missing connect payload.", {
|
|
99
|
+
status: 200,
|
|
100
|
+
code: "INVALID_RESPONSE",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
connected: true,
|
|
106
|
+
siteUrl: typeof data.siteUrl === "string" ? data.siteUrl : url ?? "",
|
|
107
|
+
newlyBound: Boolean(data.newlyBound),
|
|
108
|
+
project: data.project,
|
|
109
|
+
templates: data.templates,
|
|
110
|
+
plan: data.plan ?? null,
|
|
111
|
+
channels: data.channels ?? {
|
|
112
|
+
email: { ready: false },
|
|
113
|
+
whatsapp: { ready: false },
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Project status, channels, and plan (GET /project). */
|
|
118
|
+
async getProject() {
|
|
119
|
+
return this.request("GET", "project");
|
|
120
|
+
}
|
|
121
|
+
/** Synced templates with langs and variables (GET /templates). */
|
|
122
|
+
async getTemplates() {
|
|
123
|
+
const data = await this.request("GET", "templates");
|
|
124
|
+
return {
|
|
125
|
+
templates: Array.isArray(data.templates) ? data.templates : [],
|
|
126
|
+
limits: data.limits,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
85
129
|
async send(options) {
|
|
86
130
|
const channel = (0, validate_1.validateChannel)(options.channel);
|
|
87
131
|
const payload = buildPayload(options, channel);
|
|
132
|
+
const data = await this.request("POST", "send", payload);
|
|
133
|
+
if (data.success !== true || typeof data.logId !== "string") {
|
|
134
|
+
throw new errors_1.ZinduaError("API response missing success or logId.", {
|
|
135
|
+
status: 200,
|
|
136
|
+
code: "INVALID_RESPONSE",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
success: true,
|
|
141
|
+
channel: (data.channel === "whatsapp" ? "whatsapp" : "email"),
|
|
142
|
+
status: typeof data.status === "string" ? data.status : "queued",
|
|
143
|
+
logId: data.logId,
|
|
144
|
+
langUsed: typeof data.langUsed === "string" ? data.langUsed : undefined,
|
|
145
|
+
langFallback: typeof data.langFallback === "boolean" ? data.langFallback : undefined,
|
|
146
|
+
testMode: typeof data.testMode === "boolean" ? data.testMode : undefined,
|
|
147
|
+
project: typeof data.project === "string" ? data.project : undefined,
|
|
148
|
+
context: data.context && typeof data.context === "object"
|
|
149
|
+
? data.context
|
|
150
|
+
: undefined,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
buildHeaders() {
|
|
154
|
+
const headers = {
|
|
155
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
156
|
+
"User-Agent": USER_AGENT,
|
|
157
|
+
Accept: "application/json",
|
|
158
|
+
};
|
|
159
|
+
if (this.siteUrl) {
|
|
160
|
+
headers["X-Zindua-Site-Url"] = this.siteUrl;
|
|
161
|
+
}
|
|
162
|
+
return headers;
|
|
163
|
+
}
|
|
164
|
+
async request(method, path, body) {
|
|
165
|
+
const url = `${this.apiBase}/${path.replace(/^\//, "")}`;
|
|
88
166
|
const controller = new AbortController();
|
|
89
167
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
90
168
|
try {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
headers
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
body: JSON.stringify(payload),
|
|
169
|
+
const headers = this.buildHeaders();
|
|
170
|
+
if (body) {
|
|
171
|
+
headers["Content-Type"] = "application/json";
|
|
172
|
+
}
|
|
173
|
+
const response = await fetch(url, {
|
|
174
|
+
method,
|
|
175
|
+
headers,
|
|
176
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
100
177
|
signal: controller.signal,
|
|
101
178
|
redirect: "error",
|
|
102
179
|
});
|
|
@@ -114,25 +191,7 @@ class Zindua {
|
|
|
114
191
|
if (!response.ok) {
|
|
115
192
|
throw parseApiError(response.status, data);
|
|
116
193
|
}
|
|
117
|
-
|
|
118
|
-
throw new errors_1.ZinduaError("API response missing success or logId.", {
|
|
119
|
-
status: response.status,
|
|
120
|
-
code: "INVALID_RESPONSE",
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
return {
|
|
124
|
-
success: true,
|
|
125
|
-
channel: (data.channel === "whatsapp" ? "whatsapp" : "email"),
|
|
126
|
-
status: typeof data.status === "string" ? data.status : "queued",
|
|
127
|
-
logId: data.logId,
|
|
128
|
-
langUsed: typeof data.langUsed === "string" ? data.langUsed : undefined,
|
|
129
|
-
langFallback: typeof data.langFallback === "boolean" ? data.langFallback : undefined,
|
|
130
|
-
testMode: typeof data.testMode === "boolean" ? data.testMode : undefined,
|
|
131
|
-
project: typeof data.project === "string" ? data.project : undefined,
|
|
132
|
-
context: data.context && typeof data.context === "object"
|
|
133
|
-
? data.context
|
|
134
|
-
: undefined,
|
|
135
|
-
};
|
|
194
|
+
return data;
|
|
136
195
|
}
|
|
137
196
|
catch (err) {
|
|
138
197
|
if (err instanceof errors_1.ZinduaError)
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ZinduaErrorCode = "INVALID_API_KEY" | "INVALID_BASE_URL" | "BROWSER_FORBIDDEN" | "INVALID_OPTIONS" | "
|
|
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/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { Zindua } from "./client";
|
|
2
|
-
export type { ZinduaClientOptions, ZinduaSendContext, ZinduaSendOptions, ZinduaSendResult, SendChannel, } from "./client";
|
|
2
|
+
export type { ZinduaClientOptions, ZinduaConnectResult, ZinduaProjectInfo, ZinduaSendContext, ZinduaSendOptions, ZinduaSendResult, ZinduaTemplateInfo, SendChannel, } from "./client";
|
|
3
3
|
export { ZinduaError } from "./errors";
|
|
4
4
|
export type { ZinduaErrorCode } from "./errors";
|
|
5
5
|
export { DEFAULT_API_BASE, LIMITS } from "./validate";
|
package/dist/validate.d.ts
CHANGED
|
@@ -23,3 +23,5 @@ export declare function sanitizeVariables(variables?: Record<string, string>): R
|
|
|
23
23
|
export declare function validateOptionalEmailField(field: "cc" | "bcc" | "replyTo", value?: string): string | undefined;
|
|
24
24
|
export declare function validateAttachments(attachments?: unknown[]): unknown[] | undefined;
|
|
25
25
|
export declare function validateTimeoutMs(timeoutMs?: number): number;
|
|
26
|
+
/** Normalize site URL for WordPress one-key-one-site binding (protocol + host). */
|
|
27
|
+
export declare function validateSiteUrl(siteUrl?: string): string | undefined;
|
package/dist/validate.js
CHANGED
|
@@ -13,6 +13,7 @@ exports.sanitizeVariables = sanitizeVariables;
|
|
|
13
13
|
exports.validateOptionalEmailField = validateOptionalEmailField;
|
|
14
14
|
exports.validateAttachments = validateAttachments;
|
|
15
15
|
exports.validateTimeoutMs = validateTimeoutMs;
|
|
16
|
+
exports.validateSiteUrl = validateSiteUrl;
|
|
16
17
|
const errors_1 = require("./errors");
|
|
17
18
|
/** Matches platform `isValidE164Phone` in zindua.run API. */
|
|
18
19
|
const E164_RE = /^\+[1-9]\d{6,14}$/;
|
|
@@ -107,20 +108,26 @@ function validateRecipient(to, channel) {
|
|
|
107
108
|
if (!recipient || recipient.length > exports.LIMITS.maxToLength) {
|
|
108
109
|
throw new errors_1.ZinduaError("to is required and must be under 320 characters.", {
|
|
109
110
|
status: 0,
|
|
110
|
-
code: "
|
|
111
|
+
code: "MISSING_FIELDS",
|
|
111
112
|
});
|
|
112
113
|
}
|
|
113
114
|
if (channel === "email") {
|
|
115
|
+
if (E164_RE.test(recipient)) {
|
|
116
|
+
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" });
|
|
117
|
+
}
|
|
114
118
|
if (!EMAIL_RE.test(recipient)) {
|
|
115
119
|
throw new errors_1.ZinduaError("Invalid email address for channel email.", {
|
|
116
120
|
status: 0,
|
|
117
|
-
code: "
|
|
121
|
+
code: "INVALID_EMAIL",
|
|
118
122
|
});
|
|
119
123
|
}
|
|
120
124
|
return recipient;
|
|
121
125
|
}
|
|
126
|
+
if (EMAIL_RE.test(recipient)) {
|
|
127
|
+
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" });
|
|
128
|
+
}
|
|
122
129
|
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: "
|
|
130
|
+
throw new errors_1.ZinduaError("WhatsApp to must be E.164 with leading + (e.g. +243812345678).", { status: 0, code: "INVALID_PHONE" });
|
|
124
131
|
}
|
|
125
132
|
return recipient;
|
|
126
133
|
}
|
|
@@ -208,3 +215,21 @@ function validateTimeoutMs(timeoutMs) {
|
|
|
208
215
|
}
|
|
209
216
|
return Math.floor(timeoutMs);
|
|
210
217
|
}
|
|
218
|
+
/** Normalize site URL for WordPress one-key-one-site binding (protocol + host). */
|
|
219
|
+
function validateSiteUrl(siteUrl) {
|
|
220
|
+
if (siteUrl === undefined || siteUrl === null)
|
|
221
|
+
return undefined;
|
|
222
|
+
const trimmed = String(siteUrl).trim();
|
|
223
|
+
if (!trimmed)
|
|
224
|
+
return undefined;
|
|
225
|
+
try {
|
|
226
|
+
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
227
|
+
return `${url.protocol}//${url.host}`;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
throw new errors_1.ZinduaError("siteUrl must be a valid URL (e.g. https://example.com).", {
|
|
231
|
+
status: 0,
|
|
232
|
+
code: "INVALID_OPTIONS",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|