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 |
| React Email → Zindua | zindua.run/react-email |
| Full API reference | zindua.run/developers |
| Dashboard | zindua.run/login |
pip install zindua-sdk==1.2.1
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 |
upsert_template(slug=…, lang=…, subject=…, body_html=…) |
PUT /templates/{slug}/versions/{lang} |
Create/update a locale (React Email HTML import) |
render_template(template=…, lang=…, variables=…) |
POST /templates/render |
Interpolate without sending (prefer send) |
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
Metadata
Release files for zindua-sdk 1.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| zindua_sdk-1.2.1.tar.gz | 17.1 kB | Without provenance | Details for zindua_sdk-1.2.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| zindua_sdk-1.2.1-py3-none-any.whl | Python 3 | none | any | Details for zindua_sdk-1.2.1-py3-none-any.whl |
Total release size: 36.1 kB
Release files / zindua_sdk-1.2.1.tar.gz
| Download URL | zindua_sdk-1.2.1.tar.gz |
|---|---|
| Size | 17.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f74cfdc5adf66e379473ed3884b2d93c0e6d5e9af656fda520d77420f32057ae
|
|
BLAKE2b-256 checksum How to use checksums |
2ba35b41b6edeffabd862e2a2e55a684dbab330019cdf32df833b06f24337b1a
|
| 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.2.1-py3-none-any.whl
| Download URL | zindua_sdk-1.2.1-py3-none-any.whl |
|---|---|
| Size | 19.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c71ac7507525834127d5c897dc17aa035937e133413f516e19fdbb1152648c68
|
|
BLAKE2b-256 checksum How to use checksums |
26667800958133088470ad688cbfa006d13d0f86e55bd5aac396eae7de60b182
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.9.6
|
Security for zindua-sdk 1.2.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.