@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 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
  });
@@ -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 address, or E.164 phone (`+243…`) for WhatsApp. |
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`, … — falls back to project default. |
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 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`. |
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.4"`.
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 sendUrl;
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.4";
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
- sendUrl;
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
- const base = (0, validate_1.resolveBaseUrl)(options.baseUrl);
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 response = await fetch(this.sendUrl, {
92
- method: "POST",
93
- headers: {
94
- "Content-Type": "application/json",
95
- Authorization: `Bearer ${this.apiKey}`,
96
- "User-Agent": USER_AGENT,
97
- Accept: "application/json",
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
- if (data.success !== true || typeof data.logId !== "string") {
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" | "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/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";
@@ -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: "INVALID_RECIPIENT",
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: "INVALID_RECIPIENT",
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: "INVALID_RECIPIENT" });
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zindua/sdk",
3
- "version": "1.2.4",
3
+ "version": "1.2.7",
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",