Zindua

Zindua for Fastify

TypeScript-first Node backends with @zindua/sdk v1.2.8. send(), templates, logs, and webhooks. Same API as Next.js, built for Fastify route handlers and plugins.

npm i @zindua/sdk

Fastify is your HTTP framework (npm i fastify). Zindua ships only @zindua/sdk on npm.

TypeScript native

Full types for send(), ZinduaError, and ZinduaSendResult. Works in JavaScript too (Node 18+).

Validated payloads

Fastify JSON schema on routes + SDK checks channel/to before the HTTP call.

Email + WhatsApp

One send() for both channels. cc, bcc, replyTo on email.

Zero npm deps

@zindua/sdk uses Node fetch. Server-side only (throws in the browser).

Plugin ready

Register Zindua once with fastify.decorate() and reuse in all routes.

CLI + dashboard

Smoke test with @zindua/cli. Trace delivery with logId, Logs, and webhooks.

Quick start

Install and send

# Zindua SDK (official npm package)
npm i @zindua/sdk

# Fastify: your HTTP framework (not published by Zindua)
npm i fastify

# .env
ZINDUA_API_KEY=znd_test_your_key_here

Email OTP

import Fastify from "fastify";
import { Zindua } from "@zindua/sdk";

const fastify = Fastify({ logger: true });
const zindua = new Zindua({ apiKey: process.env.ZINDUA_API_KEY! });

fastify.post<{ Body: { to: string; code: string } }>("/send-otp", async (request, reply) => {
  const { to, code } = request.body;
  const result = await zindua.send({
    to,
    channel: "email",
    template: "otp-verification",
    lang: "fr",
    variables: { code, appName: "MyApp" },
  });
  return reply.code(202).send(result);
});

await fastify.listen({ port: 3000 });

WhatsApp OTP

fastify.post("/send-whatsapp-otp", async (request, reply) => {
  const { to, code } = request.body as { to: string; code: string };
  const result = await zindua.send({
    to,
    channel: "whatsapp",
    template: "otp-verification",
    variables: { code },
  });
  return reply.code(202).send(result);
});
TypeScript

JSON schema + typed responses

Validate request bodies in Fastify before send(). The SDK validates channel and to again before HTTP.

import type { FastifyInstance } from "fastify";
import type { ZinduaSendResult } from "@zindua/sdk";

const sendOtpSchema = {
  body: {
    type: "object",
    required: ["to", "code"],
    properties: {
      to: { type: "string", format: "email" },
      code: { type: "string", minLength: 4, maxLength: 8 },
      lang: { type: "string", enum: ["fr", "en"] },
    },
  },
} as const;

fastify.post<{ Body: { to: string; code: string; lang?: string } }>(
  "/otp/email",
  { schema: sendOtpSchema },
  async (request, reply): Promise<ZinduaSendResult> => {
    const { to, code, lang } = request.body;
    return zindua.send({
      to,
      template: "otp-verification",
      ...(lang ? { lang } : {}),
      variables: { code },
    });
  }
);
send()

Send options

All fields accepted by @zindua/sdk. Plain JavaScript works the same without types.

FieldRequiredDescription
toYesEmail if channel is email (default). E.164 phone with + for WhatsApp (e.g. +243812345678).
templateYesTemplate slug from Dashboard → Templates.
channelNo"email" (default) or "whatsapp". Must match the format of to.
langNoISO 639-1 (fr, en, sw…). Falls back to project default; langFallback: true if missing on template.
variablesNoKey/value map for {{placeholders}}. Max 50 keys, 4096 chars per value.
cc, bcc, replyToNoEmail only. Ignored when channel is whatsapp.
attachmentsNoEmail only. Array (max 5) of { url: string, filename?: string }. HTTPS URLs are fetched and attached to the email.
channelValid toRejected
email (default)user@example.com+243812345678
whatsapp+243812345678user@example.com
Email

cc, bcc, replyTo

Email-only extras. WhatsApp ignores these fields.

await zindua.send({
  to: "user@example.com",
  template: "invoice-ready",
  variables: { name: "Alex", amount: "49.00" },
  cc: "billing@myapp.com",
  replyTo: "support@myapp.com",
  // attachments: [{ url: "https://cdn.myapp.com/invoice.pdf" }],
});
Architecture

Your database, Zindua delivery

Zindua is not an auth database. Store OTP codes and sessions in Postgres, Redis, or your ORM. Use send() only to deliver the message.

// Zindua delivers the message. Your app owns OTP state.
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

fastify.post("/otp/request", async (request, reply) => {
  const { email } = request.body as { email: string };
  const code = String(Math.floor(100000 + Math.random() * 900000));

  await redis.setex(`otp:${email}`, 600, code);

  const result = await fastify.zindua.send({
    to: email,
    template: "otp-verification",
    variables: { code },
  });

  // Save logId to correlate with Dashboard → Logs or webhooks
  return reply.code(202).send({ ok: true, logId: result.logId });
});

fastify.post("/otp/verify", async (request, reply) => {
  const { email, code } = request.body as { email: string; code: string };
  const stored = await redis.get(`otp:${email}`);
  if (stored !== code) return reply.code(401).send({ error: "Invalid code" });
  await redis.del(`otp:${email}`);
  return { ok: true };
});
Response

logId and context

status: queued means accepted for delivery, not guaranteed inbox delivery. Save logId for support and tracing.

{
  "success": true,
  "status": "queued",
  "logId": "550e8400-e29b-41d4-a716-446655440000",
  "channel": "email",
  "langUsed": "fr",
  "langFallback": false,
  "testMode": false,
  "project": "My App",
  "context": {
    "plan": { "slug": "free", "emailQuota": 25000, "emailsUsed": 12 },
    "channels": { "email": true, "whatsapp": false }
  }
}
Logs & analytics

Trace delivery after send()

There is no public SDK method for logs or analytics. Delivery history and send volume live in the Zindua dashboard (session auth). From Fastify, persist result.logId in your database and correlate with dashboard Logs or inbound webhooks.

Errors

ZinduaError handling

Wrap send() in try/catch. Status 0 means the SDK rejected the payload before HTTP.

import { Zindua, ZinduaError } from "@zindua/sdk";

fastify.setErrorHandler((error, request, reply) => {
  if (error instanceof ZinduaError) {
    const status = error.status && error.status >= 400 ? error.status : 502;
    return reply.code(status).send({
      error: error.message,
      code: error.code,
      details: error.details,
    });
  }
  throw error;
});

// In a route:
try {
  const result = await zindua.send({ to, template: "otp-verification", variables: { code } });
  return reply.code(202).send(result);
} catch (e) {
  if (e instanceof ZinduaError && e.code === "TEMPLATE_NOT_FOUND") {
    return reply.code(404).send({ error: "Template missing in dashboard" });
  }
  throw e;
}
CodeHTTPMeaningAction
INVALID_EMAIL400 / 0Bad email or phone used with channel email.Use user@domain.com or channel: whatsapp.
INVALID_PHONE400 / 0Bad phone or email used with channel whatsapp.Use +243… E.164 or channel: email.
TEMPLATE_NOT_FOUND404Unknown template slug.Fix slug or create template in dashboard.
EMAIL_SERVICE_NOT_CONFIGURED422No Gmail/SMTP connected.Dashboard → project → Service.
WHATSAPP_NOT_CONNECTED422WhatsApp not linked.Dashboard → project → WhatsApp → scan QR.
QUOTA_EXCEEDED429Monthly email limit reached.Upgrade plan or wait for reset.
RATE_LIMIT_EXCEEDED429Sending too fast (WhatsApp).Wait retryAfterSec from e.details.
BROWSER_FORBIDDENSDK imported in browser bundle.Call Zindua only from Fastify routes.
Setup

Step by step

01

Create a Zindua project

Sign up at zindua.run, create a project, copy the API key. Connect email (Gmail/SMTP Service) and/or WhatsApp in the dashboard. Create templates (e.g. otp-verification).

02

Install @zindua/sdk

npm i @zindua/sdk in your Node backend. If you start a new Fastify app, also run npm i fastify. Pin the version in package.json for production.

03

Register routes

Instantiate Zindua with process.env.ZINDUA_API_KEY. Call send() from POST handlers. Store OTP codes in your own database or Redis, not in Zindua.

04

Test and trace

npx @zindua/cli@latest doctor. Send a test message, save result.logId, then open Dashboard → Logs or configure webhooks.

Plugins

Decorate Fastify

Register the SDK once with TypeScript module augmentation, reuse everywhere.

import fp from "fastify-plugin";
import { Zindua } from "@zindua/sdk";

declare module "fastify" {
  interface FastifyInstance {
    zindua: Zindua;
  }
}

export default fp(async (fastify) => {
  const client = new Zindua({
    apiKey: process.env.ZINDUA_API_KEY!,
    siteUrl: process.env.ZINDUA_SITE_URL,
    timeoutMs: 45_000,
  });
  fastify.decorate("zindua", client);
});

// In routes: fastify.zindua.send({ ... })

Sync templates from dashboard

const { templates } = await zindua.getTemplates();
// [{ slug: "otp-verification", langs: ["fr", "en"], variables: ["code", "appName"], ... }]

const project = await zindua.getProject();
// Plan, channel checklist, default language
API

SDK methods

Full surface of @zindua/sdk on Node.

MethodEndpointDescription
send()POST /sendQueue email or WhatsApp message. Returns logId and context.
getProject()GET /projectProject name, plan, channel readiness, checklist.
getTemplates()GET /templatesSynced slugs, langs, variables per template.
connect()POST /connectBind API key to site URL (WordPress-style connect).
getLog() / get_log()GET /logs/{logId}Delivery status for a single message (JS, Python SDK, and REST).
isTestMode()(local)Returns true for znd_test_ keys (sandbox, no live quota).
Security

Production checklist

Server-only API key

Keep ZINDUA_API_KEY in env or a secrets manager. Never in React bundles, mobile apps, or public repos.

Authorization: Bearer only

The SDK sends Bearer znd_live_…. Query params and X-Api-Key are rejected by the API.

Your DB, Zindua delivery

Store OTP codes, sessions, and user records in Postgres, Redis, or your ORM. Zindua only sends the message and keeps delivery logs.

Trace with logId

Every successful send() returns logId. Use Dashboard → Logs or webhooks (email.delivered / email.failed) for delivery status.

Environment

Variables

VariableRequiredExampleNotes
ZINDUA_API_KEYYesznd_test_xxxxxxxxxxxxxxxxxxxxxxxxFrom Dashboard → Projects. Use znd_test_ in staging, znd_live_ in production.
ZINDUA_API_BASE_URLNohttps://zindua.run/api/v1Optional override (e.g. http://localhost:3000/api/v1 for local API).
ZINDUA_SITE_URLNohttps://myapp.comRequired once the key is bound to a site (connect flow). Sent as X-Zindua-Site-Url.

Test first with the CLI

doctor checks the key. send smoke-tests a template. Use znd_test_ keys with isTestMode() in staging.

npx @zindua/cli@latest doctor

Python backend?

Use httpx with FastAPI for async OTP routes on Python 3.10+.