zindua-sdk
Official server-side Python SDK for Zindua: send transactional email and WhatsApp from FastAPI, Django, Flask, or any Python 3.10+ backend.
| FastAPI guide | zindua.run/fastapi |
| Full API reference | zindua.run/developers |
| Dashboard | zindua.run/login |
pip install zindua-sdk
With FastAPI helpers:
pip install "zindua-sdk[fastapi]"
Requirements: Python 3.10+. Run only on your backend — never ship ZINDUA_API_KEY to browsers or mobile apps.
Before you write code (Dashboard setup)
Zindua is an orchestration layer: you configure delivery and templates in the dashboard, then call send() from your API.
Step 1 — Create a project
- Sign in at zindua.run/login.
- Go to Projects → Create project (e.g.
My SaaS). - Copy the API key:
znd_test_…— sandbox (safe for local dev).znd_live_…— production.
- Set the default language for the project (Dashboard → project settings). Example:
fr. This language is used when you omitlanginsend().
Step 2 — Connect a delivery service
| Channel | Dashboard path | What you configure |
|---|---|---|
| Project → Service | Gmail OAuth, Outlook, or custom SMTP. Zindua sends through your provider. | |
| Project → WhatsApp | Scan QR code to link the number. Required for OTP on WhatsApp. |
Without a connected service, send() returns EMAIL_SERVICE_NOT_CONFIGURED or WHATSAPP_NOT_CONNECTED.
Step 3 — Create templates
- Open Templates → New template.
- Set a slug (e.g.
otp-verification,welcome,password-reset). You pass this slug insend(template=...). - Add variables used in the body, e.g.
{{code}},{{appName}},{{name}}. - Add language versions for each locale you support:
- French (
fr) — subject + body with{{code}}. - English (
en) — same variables, translated copy. - Swahili (
sw), etc.
- French (
- Pick one language as the template default (used when the requested
langdoes not exist).
Example template slug otp-verification:
| Lang | Subject | Body snippet |
|---|---|---|
fr (default) |
Votre code {{appName}} |
Votre code est {{code}}. Il expire dans 10 minutes. |
en |
Your {{appName}} code |
Your code is {{code}}. It expires in 10 minutes. |
Step 4 — Verify setup from your terminal
export ZINDUA_API_KEY=znd_test_your_key_here
python -m zindua doctor
python -m zindua send --to you@example.com --template otp-verification --var code=482910 --var appName=MyApp
Install and configure
pip install zindua-sdk python-dotenv
# FastAPI stack:
pip install "zindua-sdk[fastapi]" uvicorn
# .env — never commit; load via python-dotenv or your host (Railway, Fly.io, etc.)
ZINDUA_API_KEY=znd_test_xxxxxxxxxxxxxxxxxxxxxxxx
# Optional overrides
# ZINDUA_API_BASE_URL=https://zindua.run/api/v1
# ZINDUA_SITE_URL=https://yourapp.com
import os
from dotenv import load_dotenv
from zindua import Zindua
load_dotenv()
zindua = Zindua(api_key=os.environ["ZINDUA_API_KEY"])
Send a message
import asyncio
from zindua import Zindua
async def main():
client = Zindua(api_key="znd_test_xxxxxxxxxxxxxxxxxxxxxxxx")
# Email (default channel)
result = await client.send(
to="user@example.com",
template="welcome",
variables={"name": "Alex"},
)
# WhatsApp — E.164 phone with +
result = await client.send(
to="+243812345678",
channel="whatsapp",
template="otp-verification",
variables={"code": "482910", "appName": "MyApp"},
)
print(result.log_id, result.status, result.lang_used)
asyncio.run(main())
WhatsApp anti-ban robot (required)
Zindua’s anti-ban Guardian paces WhatsApp OTP. Rapid or parallel sends look like spam and can ban your linked number.
- Official Python SDK cooperates with the Guardian automatically.
- Prefer a job queue / resend cooldown in production apps.
Guide: WhatsApp anti-ban Guardian
Upgrade (already installed?)
pip install -U zindua-sdk
zindua = Zindua(api_key=os.environ["ZINDUA_API_KEY"])
Sync helpers exist for scripts and WSGI apps: client.send_sync(...), client.get_log_sync(...).
Channel and to must match
The SDK validates locally before calling the API.
channel |
Valid to |
Rejected |
|---|---|---|
email (default) |
user@example.com |
+243812345678 |
whatsapp |
+243812345678 |
user@example.com |
Template language (lang)
When you create a project, you choose a default language. Each template can have several language versions.
What you pass to send() |
What Zindua renders |
|---|---|
No lang |
Project default language |
lang="en" and English exists on the template |
English version, result.lang_used == "en" |
lang="de" but only fr / en exist |
Template default + result.lang_fallback is True |
# Use the user's locale from your database or Accept-Language header
user_locale = "fr" # optional
result = await zindua.send(
to=contact,
channel=channel,
template="otp-verification",
lang=user_locale, # omit to use project default
variables={"code": code, "appName": "MyApp"},
)
if result.lang_fallback:
# Requested lang was missing; template default was used
pass
List available langs per template:
data = await zindua.get_templates()
for tpl in data["templates"]:
print(tpl.slug, tpl.langs, tpl.default_lang, tpl.variables)
End-to-end: FastAPI backend + Next.js frontend
Rule: the browser never sees ZINDUA_API_KEY. The Next.js app calls your FastAPI API; FastAPI calls Zindua.
[Next.js browser] → POST /api/auth/request-otp → [FastAPI]
↓
store OTP in Redis
zindua.send(...)
↓
[Zindua → Gmail / WhatsApp]
FastAPI — generate OTP, send, verify
# main.py
import os
import secrets
from contextlib import asynccontextmanager
import redis.asyncio as redis
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, EmailStr
from zindua import Zindua, ZinduaError
load_dotenv()
redis_client: redis.Redis | None = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global redis_client
redis_client = redis.from_url(os.environ["REDIS_URL"])
yield
await redis_client.aclose()
app = FastAPI(lifespan=lifespan)
zindua = Zindua(api_key=os.environ["ZINDUA_API_KEY"])
class RequestOtpBody(BaseModel):
email: EmailStr
lang: str | None = None # e.g. "fr", "en" — optional
class VerifyOtpBody(BaseModel):
email: EmailStr
code: str
@app.post("/auth/request-otp")
async def request_otp(body: RequestOtpBody):
code = f"{secrets.randbelow(1_000_000):06d}"
await redis_client.setex(f"otp:{body.email}", 600, code)
try:
result = await zindua.send(
to=body.email,
channel="email",
template="otp-verification",
lang=body.lang,
variables={"code": code, "appName": "MyApp"},
)
except ZinduaError as exc:
raise HTTPException(
status_code=exc.status or 502,
detail={"code": exc.code, "error": str(exc)},
) from exc
return {"ok": True, "logId": result.log_id, "status": result.status}
@app.post("/auth/verify-otp")
async def verify_otp(body: VerifyOtpBody):
stored = await redis_client.get(f"otp:{body.email}")
if not stored or stored.decode() != body.code:
raise HTTPException(status_code=401, detail="Invalid or expired code")
await redis_client.delete(f"otp:{body.email}")
return {"ok": True}
Run locally:
uvicorn main:app --reload
# Open http://127.0.0.1:8000/docs to test routes
FastAPI — dependency injection
from fastapi import Depends
from zindua.integrations.fastapi import ZinduaDep
@app.post("/send-otp")
async def send_otp(to: str, code: str, zindua: ZinduaDep):
return await zindua.send(
to=to,
template="otp-verification",
variables={"code": code, "appName": "MyApp"},
)
Install: pip install "zindua-sdk[fastapi]".
Next.js — call your FastAPI backend
// app/login/page.tsx — client component (no Zindua key here)
"use client";
export default function LoginPage() {
async function requestOtp(email: string, lang?: string) {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/request-otp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, lang }),
});
if (!res.ok) throw new Error("Failed to send OTP");
return res.json();
}
// ... UI that collects email, calls requestOtp(), then verify endpoint
}
# .env.local (Next.js — public URL of your FastAPI only)
NEXT_PUBLIC_API_URL=http://127.0.0.1:8000
Alternatively, proxy through a Next.js Route Handler (same pattern as @zindua/sdk on Node):
// app/api/auth/request-otp/route.ts
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const body = await req.json();
const upstream = await fetch(`${process.env.FASTAPI_URL}/auth/request-otp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return NextResponse.json(await upstream.json(), { status: upstream.status });
}
Use one place for the Zindua key: either FastAPI or a Node BFF — not both unless you run separate projects.
Email attachments
Email only. Pass up to 5 HTTPS URLs; Zindua fetches them server-side and attaches the files.
result = await zindua.send(
to="user@example.com",
template="invoice",
variables={"name": "Sarah", "invoiceNumber": "INV-1042"},
attachments=[
{"url": "https://cdn.example.com/invoices/inv-1042.pdf", "filename": "invoice.pdf"},
],
)
Track delivery (get_log)
Every successful send() returns a log_id. Poll status from your backend:
result = await zindua.send(
to="user@example.com",
template="welcome",
variables={"name": "Alex"},
)
log = await zindua.get_log(result.log_id)
print(log.status, log.error, log.opened_at)
Statuses include queued, sent, delivered, failed, etc. For full history, use Dashboard → Logs or configure webhooks.
Error handling
from zindua import ZinduaError
try:
result = await zindua.send(
to="user@example.com",
template="otp-verification",
variables={"code": "482910"},
)
except ZinduaError as exc:
# exc.status — HTTP status (400, 422, 429, …)
# exc.code — platform code (TEMPLATE_NOT_FOUND, QUOTA_EXCEEDED, …)
print(exc.code, exc.status, exc.details)
| Code | Typical cause | Fix |
|---|---|---|
TEMPLATE_NOT_FOUND |
Wrong slug | Match Dashboard → Templates slug |
EMAIL_SERVICE_NOT_CONFIGURED |
No Gmail/SMTP | Project → Service |
WHATSAPP_NOT_CONNECTED |
QR not scanned | Project → WhatsApp |
QUOTA_EXCEEDED |
Plan limit | Upgrade or wait for reset |
INVALID_EMAIL / INVALID_PHONE |
to does not match channel |
Fix payload or let SDK validate early |
CLI (smoke tests)
Ships with the package — no separate install.
export ZINDUA_API_KEY=znd_test_...
python -m zindua doctor
python -m zindua send --to user@example.com --template otp-verification --var code=482910
python -m zindua send --to +243812345678 --channel whatsapp --template otp-verification --var code=482910
API methods
| Method | HTTP | Description |
|---|---|---|
send() |
POST /send |
Queue email or WhatsApp |
get_log() |
GET /logs/{logId} |
Delivery status for one message |
get_project() |
GET /project |
Plan, channels, project metadata |
get_templates() |
GET /templates |
Slugs, langs, variables |
connect() |
POST /connect |
Bind key to site URL (WordPress-style) |
is_test_mode() |
(local) | True for znd_test_ keys |
Security
| Do | Don't |
|---|---|
Store ZINDUA_API_KEY in env / secrets manager |
Expose the key in React, Flutter, or Swagger on a public URL |
| Call Zindua from FastAPI, Django, Celery, cron | Import zindua in a public Jupyter notebook with a live key |
| Store OTPs in Redis/Postgres with TTL | Rely on Zindua to store verification codes |
Use znd_test_ in staging |
Share znd_live_ keys in chat or git |
Client options
Zindua(
api_key="znd_live_xxxxxxxxxxxxxxxxxxxxxxxx",
base_url="http://localhost:3000/api/v1", # optional — local Zindua dev only
timeout=30.0, # seconds, max 120
site_url="https://yourapp.com", # optional — WordPress / connect flows
)
Environment variables read by get_zindua():
| Variable | Required | Description |
|---|---|---|
ZINDUA_API_KEY |
Yes | Project API key |
ZINDUA_API_BASE_URL |
No | Override API base (default https://zindua.run/api/v1) |
ZINDUA_SITE_URL |
No | Sent as X-Zindua-Site-Url when set |
License
MIT © Zindua
Release files for zindua-sdk 1.0.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| zindua_sdk-1.0.3.tar.gz | 16.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| zindua_sdk-1.0.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 34.5 kB
Release files / zindua_sdk-1.0.3.tar.gz
| Download URL | zindua_sdk-1.0.3.tar.gz |
|---|---|
| Size | 16.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
140d457b7795824393dd9214df2b5f7d9a1e63a4e106404e60c8265ad6c5c602
|
|
BLAKE2b-256 checksum How to use checksums |
4c4d26a631c2c56e6dd40647ba11bdf54c26981833b484133776a3d1f3f2a50f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Release files / zindua_sdk-1.0.3-py3-none-any.whl
| Download URL | zindua_sdk-1.0.3-py3-none-any.whl |
|---|---|
| Size | 18.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5833d7fc979224b200e9cb5c3e8724f78cc11c6da98b9a7f7c42b07992f40ba2
|
|
BLAKE2b-256 checksum How to use checksums |
a02c53f1cd0083bc446833b9e60d8b06dbf825d3b4ff4eb13396e545d01629bf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|