@zindua/sdk
v1.2.7
Published
Official Zindua SDK for Node.js — transactional email and WhatsApp via POST /api/v1/send.
Maintainers
Readme
@zindua/sdk
Official server-side SDK for Zindua: send transactional email and WhatsApp messages with one API.
| | | |---|---| | Full API reference | zindua.run/developers | | Dashboard (keys, templates, services) | zindua.run/login | | Pricing | zindua.run/pricing |
npm install @zindua/sdkRequirements: Node.js 18+, run only on your backend (not in the browser).
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+).
Quick start
1. Create a Zindua project
- Sign in at zindua.run/login.
- Open Projects → create a project (or use an existing one).
- Copy the API key (
znd_live_…for production,znd_test_…for sandbox). - In the project: connect Service (Gmail, SMTP, …) for email, and/or link WhatsApp for OTP.
- 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).
2. Install and configure
npm install @zindua/sdk# .env — never commit real keys; never expose in frontend code
ZINDUA_API_KEY=znd_live_xxxxxxxxxxxxxxxxxxxxxxxximport { Zindua } from "@zindua/sdk";
export const zindua = new Zindua({
apiKey: process.env.ZINDUA_API_KEY!,
});3. Send a message
// Email (default channel)
await zindua.send({
to: "[email protected]",
template: "welcome",
variables: { name: "Alex" },
});
// WhatsApp — E.164 phone with + (channel is required for WhatsApp)
await zindua.send({
to: "+243812345678",
channel: "whatsapp",
template: "otp",
variables: { code: "482910" },
});Channel and to must match
The SDK checks before calling the API (same rules as zindua.run). A phone number cannot be sent as email, and an email cannot be sent on WhatsApp.
| channel | Valid to | Rejected (not sent) |
|-----------|------------|---------------------|
| email (default) | [email protected] | +243812345678 |
| whatsapp | +243812345678 | [email protected] |
// ❌ Phone with default channel email — rejected locally (INVALID_EMAIL)
await zindua.send({ to: "+243812345678", template: "otp", variables: { code: "1" } });
// ❌ Email with WhatsApp channel — rejected locally (INVALID_PHONE)
await zindua.send({
to: "[email protected]",
channel: "whatsapp",
template: "otp",
variables: { code: "1" },
});
// ✅ User chose email for OTP
await zindua.send({
to: "[email protected]",
channel: "email",
template: "otp",
variables: { code: "482910" },
});
// ✅ User chose WhatsApp for OTP
await zindua.send({
to: "+243812345678",
channel: "whatsapp",
template: "otp",
variables: { code: "482910" },
});Languages (multilingual projects)
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.
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.
| What you send | What Zindua uses |
|---------------|------------------|
| No lang | Project default (e.g. fr) |
| lang: "en" and English exists on the template | English version |
| lang: "de" but only fr / en exist | Default language for that template + langFallback: true |
// Default language of the project
await zindua.send({
to: "[email protected]",
template: "otp",
variables: { code: "482910" },
});
// Explicit language (email or WhatsApp — same `lang` for both channels)
const result = await zindua.send({
to: "+243812345678",
channel: "whatsapp",
template: "otp",
lang: "fr",
variables: { code: "482910" },
});
console.log(result.langUsed); // e.g. "fr" — language actually rendered
console.log(result.langFallback); // true if `lang` was missing and fallback was usedOTP with the user’s locale (optional):
await zindua.send({
to: contact,
channel,
template: "otp",
lang: userLocale, // e.g. "fr", "en" — omit if you want project default
variables: { code },
});Allowed codes: short ISO 639-1 style (fr, en, sw, en-us). Invalid format is rejected by the SDK before the HTTP call.
Plans and channels
| Plan | Email API | WhatsApp | Email / month | WhatsApp OTP / month | |------|-----------|----------|---------------|----------------------| | Free | Yes, if a Service is connected on the project | Yes | 25,000 | 200 | | Pro | Yes | Yes | 160,000 | 20,000 | | Team | Yes | Yes | High volume | Unlimited |
On Free, email works through your connected provider (Gmail, SMTP, etc.). Pro and Team raise template counts and monthly limits. Details: pricing.
| | Free | Pro / Team | |---|------|------------| | Templates per project | 5 | 50+ | | Languages per template | 3 | 10+ |
Use znd_test_… keys to try the API without consuming live quota (sandbox behaviour).
One project or several?
| Setup | When it fits | |-------|----------------| | One Zindua project, one API key | One product, one sender, shared templates and quota. | | Several Zindua projects, one key each | Several clients or brands: separate templates, Gmail accounts, logs, and quotas. |
Several keys in one backend
ZINDUA_KEY_CLIENT_A=znd_live_aaaaaaaaaaaaaaaaaaaaaaaa
ZINDUA_KEY_CLIENT_B=znd_live_bbbbbbbbbbbbbbbbbbbbbbbbimport { Zindua } from "@zindua/sdk";
const clients = {
clientA: new Zindua({ apiKey: process.env.ZINDUA_KEY_CLIENT_A! }),
clientB: new Zindua({ apiKey: process.env.ZINDUA_KEY_CLIENT_B! }),
};
await clients.clientA.send({
to: "[email protected]",
template: "otp",
variables: { code: "123456" },
});Each key only accesses its Zindua project (templates, service, usage).
Several templates in one application
You do not need a separate SDK client per template. One Zindua instance, change the template slug and variables per send:
// OTP
await zindua.send({
to: "[email protected]",
template: "otp",
variables: { code: "482910" },
});
// Welcome email — different slug, different variables
await zindua.send({
to: "[email protected]",
template: "user-welcome",
variables: { name: "Alex" },
});Keep slugs in one place so your code stays clear:
const Template = {
otp: "otp",
welcome: "user-welcome",
resetPassword: "password-reset",
} as const;
await zindua.send({
to: email,
template: Template.welcome,
variables: { name },
});Each template defines its own {{variables}} in the dashboard. The SDK does not merge templates: one send() call = one template slug.
Framework examples
Use the same pattern everywhere: your server holds the API key; the client app calls your API.
Next.js (App Router)
// lib/zindua.ts
import { Zindua } from "@zindua/sdk";
export const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });// app/api/notify/route.ts
import { zindua } from "@/lib/zindua";
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { channel, contact, code, lang } = await req.json();
if (channel !== "email" && channel !== "whatsapp") {
return NextResponse.json({ error: "channel must be email or whatsapp" }, { status: 400 });
}
// contact = email string OR +243… phone, depending on channel
const result = await zindua.send({
to: contact,
channel,
template: "otp",
...(lang ? { lang } : {}),
variables: { code },
});
return NextResponse.json(result, { status: 202 });
}Express
import express from "express";
import { Zindua } from "@zindua/sdk";
const app = express();
app.use(express.json());
const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
app.post("/api/notify", async (req, res) => {
const { channel, contact, code } = req.body;
if (channel !== "email" && channel !== "whatsapp") {
return res.status(400).json({ error: "channel must be email or whatsapp" });
}
try {
const result = await zindua.send({
to: contact,
channel,
template: "otp",
variables: { code },
});
res.status(202).json(result);
} catch (err) {
res.status(500).json({ error: "Send failed" });
}
});Fastify
import Fastify from "fastify";
import { Zindua } from "@zindua/sdk";
const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
const app = Fastify();
app.post<{ Body: { channel: "email" | "whatsapp"; contact: string; code: string } }>(
"/notify",
async (req, reply) => {
const { channel, contact, code } = req.body;
if (channel !== "email" && channel !== "whatsapp") {
return reply.status(400).send({ error: "channel must be email or whatsapp" });
}
const result = await zindua.send({
to: contact,
channel,
template: "otp",
variables: { code },
});
return reply.status(202).send(result);
}
);Hono
import { Hono } from "hono";
import { Zindua } from "@zindua/sdk";
const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });
const app = new Hono();
app.post("/notify", async (c) => {
const { channel, contact, code } = await c.req.json();
if (channel !== "email" && channel !== "whatsapp") {
return c.json({ error: "channel must be email or whatsapp" }, 400);
}
const result = await zindua.send({
to: contact,
channel,
template: "otp",
variables: { code },
});
return c.json(result, 202);
});Mobile apps (React Native, Flutter)
Do not embed @zindua/sdk or znd_live_… in the app. Call your backend route above from the device.
WordPress site binding & connect()
Each API key can be linked to one site URL (used by the WordPress plugin). Pass siteUrl on the client so every request includes X-Zindua-Site-Url:
const zindua = new Zindua({
apiKey: process.env.ZINDUA_API_KEY!,
siteUrl: "https://shop.example.com",
});
// First connect binds the key to this URL
const status = await zindua.connect();
console.log(status.project.name, status.templates);
// Later: list templates with langs (fr, en, …)
const { templates } = await zindua.getTemplates();| Error code | Meaning |
|------------|---------|
| SITE_ALREADY_BOUND | Key already used on another site — create a new project or regenerate the key |
| SITE_MISMATCH | siteUrl does not match the registered site |
| SITE_URL_REQUIRED | Key is bound; update the SDK and set siteUrl |
Configuration options
| Option | Default | Description |
|--------|---------|-------------|
| apiKey | — | Required. znd_live_… or znd_test_… from the dashboard. |
| baseUrl | https://zindua.run/api/v1 | Override only for local testing (http://localhost:3000/api/v1). |
| timeoutMs | 30000 | Request timeout (max 120000). |
| siteUrl | — | Your app origin (https://example.com). Required once the key is site-bound. |
const zindua = new Zindua({
apiKey: process.env.ZINDUA_API_KEY!,
siteUrl: process.env.APP_URL,
baseUrl: process.env.ZINDUA_API_BASE_URL, // optional
timeoutMs: 45_000,
});Send options
| Field | Required | Description |
|-------|----------|-------------|
| 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. |
| template | Yes | Template slug from your dashboard. |
| channel | No | "email" (default) or "whatsapp". Must match the format of to. |
| lang | No | Optional. fr, en, sw, … — if omitted, project default; if missing on template, fallback + langFallback: true. |
| variables | No | {{placeholders}} in the template. |
| cc, bcc, replyTo | No | Email only. |
API key, recipient, and delivery — what is checked when
| Situation | When you know | What to do |
|-----------|---------------|------------|
| Wrong or missing API key | Immediately (401, INVALID_API_KEY / API_KEY_NOT_FOUND) | Copy key from Dashboard → Projects |
| Phone sent as email (or the opposite) | Immediately in the SDK (INVALID_EMAIL / INVALID_PHONE, HTTP status 0) | Match channel and to (see above) |
| Malformed email or phone | Immediately (SDK + API 400) | Fix format: [email protected], +243812345678 |
| Typo in email ([email protected]) | Not blocked at send time | Request may succeed (202); provider may bounce later |
| Email inbox does not exist | Not verified upfront | Check Dashboard → Logs with logId; configure webhooks (email.delivered / email.failed) |
| Phone valid in E.164 but no WhatsApp on that number | Not verified upfront | WhatsApp send can fail after accept; status failed in logs |
| Template slug wrong | API 404 TEMPLATE_NOT_FOUND | Create template or fix slug |
| No Gmail/SMTP / WhatsApp not linked | API 422 | Connect Service or WhatsApp on the project |
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.
const result = await zindua.send({
to: "[email protected]",
template: "otp",
variables: { code: "123456" },
});
// Save logId — check delivery in the dashboard or via webhooks
console.log(result.logId, result.status, result.langUsed);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.
Successful response
const result = await zindua.send({ /* … */ });| Field | Meaning |
|-------|---------|
| success | true |
| status | queued, processing, or sent (test keys often return sent immediately). |
| logId | Id for support / logs in the dashboard. |
| channel | email or whatsapp |
| langUsed | Language version used for this send |
| langFallback | true if requested lang was missing and a fallback version was used |
| context | Workspace snapshot (see below). |
context object
The API returns metadata so you can log or debug without exposing secrets:
{
"context": {
"project": { "name": "My App", "slug": "my-app" },
"apiKey": { "mode": "live", "suffix": "ejho" },
"plan": { "slug": "free", "name": "Free", "emailQuota": 25000, "emailsUsed": 12 },
"channels": { "email": true, "whatsapp": false }
}
}context.apiKey.suffix— last 4 characters of the key (match in the dashboard).context.plan— current plan and usage for the workspace linked to this key.context.channels— whether email / WhatsApp is ready for this project.
The full API key is never returned in responses.
Errors
Wrap sends in try/catch and handle ZinduaError:
import { Zindua, ZinduaError } from "@zindua/sdk";
try {
await zindua.send({ to: "[email protected]", template: "otp", variables: { code: "1" } });
} catch (e) {
if (e instanceof ZinduaError) {
console.log(e.status); // HTTP status
console.log(e.code); // machine-readable code
console.log(e.message); // human-readable
console.log(e.details); // optional: hint, context, retryAfterSec, …
}
}Error codes
| Code | HTTP | What it means | What to do |
|------|------|---------------|------------|
| INVALID_API_KEY | 401 | Missing or malformed key. | Use Authorization: Bearer znd_live_… (24 chars after prefix). |
| API_KEY_NOT_FOUND | 401 | Key not linked to a project. | Copy the key from Dashboard → your project. |
| ORIGIN_NOT_ALLOWED | 403 | Browser origin blocked. | Prefer server-side SDK; or add origin in Dashboard → Settings → Integrations. |
| MISSING_FIELDS | 400 / 0 | to or template missing. | Send both. Status 0 = caught by the SDK before HTTP. |
| INVALID_EMAIL | 400 / 0 | Bad email, or phone used with channel email. | Use [email protected], or set channel: "whatsapp" for +243…. |
| INVALID_PHONE | 400 / 0 | Bad WhatsApp number, or email used with channel whatsapp. | Use +243812345678, or set channel: "email" for an address. |
| NO_SUBSCRIPTION | 403 | Workspace has no active plan. | Open Dashboard → Billing or contact support. |
| EMAIL_NOT_AVAILABLE | 403 | Email not allowed on current plan setup. | Connect a Service on the project (Free), or upgrade plan. |
| EMAIL_SERVICE_NOT_CONFIGURED | 422 | No Gmail/SMTP connected. | Dashboard → project → Service. |
| QUOTA_EXCEEDED | 429 | Monthly email limit reached. | Upgrade plan or wait for reset. |
| WHATSAPP_NOT_AVAILABLE | 403 | WhatsApp not on plan. | Check pricing. |
| WHATSAPP_NOT_CONNECTED | 422 | WhatsApp not linked. | Dashboard → project → WhatsApp → scan QR. |
| WHATSAPP_PAUSED | 422 | WhatsApp session paused. | Resume in the dashboard. |
| WHATSAPP_QUOTA_EXCEEDED | 429 | Monthly WhatsApp limit reached. | Upgrade or wait. |
| RATE_LIMIT_EXCEEDED | 429 | Sending too fast (WhatsApp). | Wait retryAfterSec from e.details, then retry. |
| TEMPLATE_NOT_FOUND | 404 | Unknown template slug. | Create template or fix slug; check availableTemplateSlugs in details. |
| TEMPLATE_NO_CONTENT | 404 | Template has no language version. | Add content in Dashboard → Templates. |
| TEMPLATE_NO_WHATSAPP_BODY | 422 | No plain text for WhatsApp. | Add a text body for that language. |
| QUEUE_UNAVAILABLE | 503 | Temporary delivery queue issue. | Retry shortly; contact support if it persists. |
| BROWSER_FORBIDDEN | — | SDK used in browser. | Move the call to your backend. |
| REQUEST_TIMEOUT | — | Request exceeded timeoutMs. | Increase timeout or retry. |
Many errors include a context field (same shape as success) with plan and project to help you debug.
Best practices
- Store the API key in environment variables or a secrets manager.
- Call Zindua from your API routes only, not from React/Vue/mobile bundles.
- Use
znd_test_…in staging;znd_live_…in production. - Pin the SDK version in
package.json, e.g."@zindua/sdk": "1.2.6". - One Zindua project per client when quotas, senders, or templates must stay isolated.
Without the SDK (HTTP)
curl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer znd_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"to":"[email protected]","template":"welcome","variables":{"name":"Alex"}}'See HTTP / cURL.
API reference
| Export | Description |
|--------|-------------|
| Zindua | Client class |
| ZinduaError | Error type (status, code, message, details) |
| ZinduaSendResult | Success payload type |
| ZinduaSendContext | Type of result.context |
| DEFAULT_API_BASE | https://zindua.run/api/v1 |
| LIMITS | Client-side validation limits |
License
MIT © Zindua
