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.
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.
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_hereEmail 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);
});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 options
All fields accepted by @zindua/sdk. Plain JavaScript works the same without types.
| Field | Required | Description |
|---|---|---|
| to | Yes | Email if channel is email (default). E.164 phone with + for WhatsApp (e.g. +243812345678). |
| template | Yes | Template slug from Dashboard → Templates. |
| channel | No | "email" (default) or "whatsapp". Must match the format of to. |
| lang | No | ISO 639-1 (fr, en, sw…). Falls back to project default; langFallback: true if missing on template. |
| variables | No | Key/value map for {{placeholders}}. Max 50 keys, 4096 chars per value. |
| cc, bcc, replyTo | No | Email only. Ignored when channel is whatsapp. |
| attachments | No | Email only. Array (max 5) of { url: string, filename?: string }. HTTPS URLs are fetched and attached to the email. |
| channel | Valid to | Rejected |
|---|---|---|
| email (default) | user@example.com | +243812345678 |
| +243812345678 | user@example.com |
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" }],
});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 };
});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 }
}
}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.
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;
}| Code | HTTP | Meaning | Action |
|---|---|---|---|
| INVALID_EMAIL | 400 / 0 | Bad email or phone used with channel email. | Use user@domain.com or channel: whatsapp. |
| INVALID_PHONE | 400 / 0 | Bad phone or email used with channel whatsapp. | Use +243… E.164 or channel: email. |
| TEMPLATE_NOT_FOUND | 404 | Unknown template slug. | Fix slug or create template in dashboard. |
| EMAIL_SERVICE_NOT_CONFIGURED | 422 | No Gmail/SMTP connected. | Dashboard → project → Service. |
| WHATSAPP_NOT_CONNECTED | 422 | WhatsApp not linked. | Dashboard → project → WhatsApp → scan QR. |
| QUOTA_EXCEEDED | 429 | Monthly email limit reached. | Upgrade plan or wait for reset. |
| RATE_LIMIT_EXCEEDED | 429 | Sending too fast (WhatsApp). | Wait retryAfterSec from e.details. |
| BROWSER_FORBIDDEN | — | SDK imported in browser bundle. | Call Zindua only from Fastify routes. |
Step by step
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).
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.
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.
Test and trace
npx @zindua/cli@latest doctor. Send a test message, save result.logId, then open Dashboard → Logs or configure webhooks.
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 languageSDK methods
Full surface of @zindua/sdk on Node.
| Method | Endpoint | Description |
|---|---|---|
| send() | POST /send | Queue email or WhatsApp message. Returns logId and context. |
| getProject() | GET /project | Project name, plan, channel readiness, checklist. |
| getTemplates() | GET /templates | Synced slugs, langs, variables per template. |
| connect() | POST /connect | Bind 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). |
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.
Variables
| Variable | Required | Example | Notes |
|---|---|---|---|
| ZINDUA_API_KEY | Yes | znd_test_xxxxxxxxxxxxxxxxxxxxxxxx | From Dashboard → Projects. Use znd_test_ in staging, znd_live_ in production. |
| ZINDUA_API_BASE_URL | No | https://zindua.run/api/v1 | Optional override (e.g. http://localhost:3000/api/v1 for local API). |
| ZINDUA_SITE_URL | No | https://myapp.com | Required 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 doctorPython backend?
Use httpx with FastAPI for async OTP routes on Python 3.10+.