npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zindua/sdk

v1.2.7

Published

Official Zindua SDK for Node.js — transactional email and WhatsApp via POST /api/v1/send.

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/sdk

Requirements: 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

  1. Sign in at zindua.run/login.
  2. Open Projects → create a project (or use an existing one).
  3. Copy the API key (znd_live_… for production, znd_test_… for sandbox).
  4. In the project: connect Service (Gmail, SMTP, …) for email, and/or link WhatsApp for OTP.
  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).

2. Install and configure

npm install @zindua/sdk
# .env — never commit real keys; never expose in frontend code
ZINDUA_API_KEY=znd_live_xxxxxxxxxxxxxxxxxxxxxxxx
import { 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 used

OTP 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_bbbbbbbbbbbbbbbbbbbbbbbb
import { 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

  1. Store the API key in environment variables or a secrets manager.
  2. Call Zindua from your API routes only, not from React/Vue/mobile bundles.
  3. Use znd_test_… in staging; znd_live_… in production.
  4. Pin the SDK version in package.json, e.g. "@zindua/sdk": "1.2.6".
  5. 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