One API.
Email + WhatsApp.
A single route: POST /api/v1/send at https://zindua.run/api/v1/send. Use HTTP / cURL from any stack, or the Node SDK if you prefer. Set channel to email or whatsapp.
You bring delivery: Gmail, SendGrid, SMTP, and your WhatsApp line. Zindua is not a hosted ESP like Resend or Unosend. Domain & custom sender setup →
Start by product
Choose the integration path that fits your stack. All options use the same API and templates.
HTTP API
No SDK required. Use POST /api/v1/send from any backend.
Open guideCLI
Send test OTPs, inspect project status, and run doctor checks.
Open guideNext.js starter
Scaffold a full auth app with @zindua/create-app.
Open guidePHP SDK
Laravel, Symfony, and WordPress custom integrations.
Open guideWordPress plugin
No-code OTP flows for WordPress and WooCommerce.
Open guideDomain (custom sender)
If you want to send from your own domain (noreply@yourcompany.com), this is the checklist. Zindua is an orchestration layer: you connect a provider in Service, and Domain helps you align DNS records.
Zindua does not become your SMTP provider when you add a domain here. Outbound email still goes through the provider you connect under Service.
Use Service to choose who sends mail. Use Domains & DNS to paste records at Cloudflare, OVH, or Route 53.
How the pieces connect
Your app
POST /api/v1/send
API key + template slug
Zindua
Queue → email worker
Templates, logs, routing
Service (dashboard)
SendGrid / Gmail / SMTP
fromEmail = noreply@acme.com
Domains & DNS (dashboard)
SPF / DKIM / DMARC / Zindua TXT at your DNS host. Helps deliverability; does not replace Service.
What you see in the dashboard
Provider
SendGrid, Gmail, Outlook, Mailgun, custom SMTP
From name
Acme
From email
noreply@acme.com
Credentials
API key or OAuth (depends on provider)
DNS checklist only. Does not replace provider verification or SMTP credentials.
Where to open these screens
- Sign inUse your Zindua account.
- DashboardOpen the developer dashboard after login.
- Your projectPick the project in the left sidebar.
- ServiceConnect Gmail, SendGrid, SMTP, etc. Set From name and From email.
- Domains & DNSAdd your hostname and copy DNS records (Pro/Team plans).
Steps 4 and 5 are separate menus: use Service first, then Domains & DNS when you need a custom domain on Pro/Team.
Configure DNS modal (example for acme.com)
_zindua.acme.com
Proves you control the zone. Required for Verified status.
zindua._domainkey.acme.com
Public key for future signing; align with your ESP today.
acme.com
Hint based on your Service provider (e.g. include:sendgrid.net).
_dmarc.acme.com
Policy record for receivers.
bounce.acme.com
Optional; shown when ZINDUA_BOUNCE_MX_HOST is set.
Domains & DNS (dashboard)
Pro/Team projects can add a sending hostname and get DNS records to paste at their DNS host. This does not activate sending by itself.
- “Verified” means we found the Zindua ownership TXT on your DNS. It does not mean Zindua is now your SMTP provider.
- DKIM keys generated here are stored for future signing. Today, outbound mail is signed by your connected provider, not by Zindua’s worker.
Recommended workflow
- 1.Connect Service (Gmail, SendGrid, SMTP, …).
- 2.Verify the domain with that provider if required.
- 3.Add the same domain here and align SPF/DKIM/DMARC.
- 4.Set fromEmail in Service to an address on that domain.
- 5.Call POST /api/v1/send.
Compare pricing and ESP features on /compare.
Example: send from noreply@acme.com with SendGrid
- 1
Dashboard → Service
Connect SendGrid with your API key.
- From name: Acme
- From email: noreply@acme.com
- 2
SendGrid dashboard
Authenticate domain acme.com (Sender Authentication).
- Complete their DNS steps before going live.
- 3
Dashboard → Domains & DNS
Add domain acme.com, open Configure DNS.
- Copy each TXT/MX into Cloudflare (or OVH, Route 53, …).
- Click Verify all until Status = Verified.
- 4
Your backend
Send with the project API key.
- From header in the delivered email = noreply@acme.com (from Service).
Step 4: API request
curl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer znd_live_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"to": "user@example.com",
"channel": "email",
"template": "welcome",
"variables": { "name": "Alex" }
}'POST /api/v1/send
All messages use this endpoint. Change channel per request without changing URL or API key.
Authorization: Bearer znd_live_xxx
Copy your project key from Dashboard → Projects → your project (starts with znd_live_ or znd_test_).
| Field | Required | Description |
|---|---|---|
| to | Yes | Recipient. Email if channel is email (default). E.164 phone with + for WhatsApp (e.g. +243812345678). |
| template | Yes | Template slug from your dashboard (same slug for email and WhatsApp). |
| channel | No | "email" (default) or "whatsapp". One route, switch channel per request. |
| lang | No | ISO 639-1 code (fr, en, sw…). Falls back to project default if missing. |
| variables | No | Key/value map for {{placeholders}} in the template. |
| cc, bcc, replyTo, attachments | No | Email only. Ignored when channel is whatsapp. |
{
"to": "user@example.com",
"channel": "email",
"template": "welcome",
"variables": { "name": "Alex" }
}{
"to": "+243812345678",
"channel": "whatsapp",
"template": "otp-verification",
"variables": { "code": "4592" }
}{
"success": true,
"status": "queued",
"logId": "uuid",
"channel": "email",
"langUsed": "fr",
"langFallback": false,
"testMode": false,
"project": "Overlook",
"context": {
"project": { "id": "uuid", "name": "Overlook", "slug": "overlook", "teamId": "uuid" },
"apiKey": { "mode": "live", "prefix": "znd_live_", "suffix": "ejho" },
"plan": {
"slug": "free",
"name": "Free",
"status": "active",
"emailApiEnabled": false,
"whatsappEnabled": true,
"emailQuota": 25000,
"emailsUsed": 0
},
"channels": { "email": true, "whatsapp": false }
}
}Full HTTP / cURL guide . Copy-paste examples for email, WhatsApp, fetch, and error handling.
HTTP / cURL
Copy-paste examples that work without installing a package. Replace ZINDUA_API_KEY with your project key.
Every integration can use plain HTTP; no SDK required. Send JSON to the endpoint below with your project API key (znd_live_… or znd_test_…). Works from curl, Postman, Go, PHP, Ruby, Java, or any HTTP client.
https://zindua.run/api/v1/send| Header | Value | Notes |
|---|---|---|
| Authorization | Bearer znd_live_xxxxxxxx | Your project API key from the dashboard. |
| Content-Type | application/json | Request body must be JSON. |
curl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer $ZINDUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "user@example.com",
"channel": "email",
"template": "welcome",
"variables": { "name": "Alex" }
}'curl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer $ZINDUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+243812345678",
"channel": "whatsapp",
"template": "otp-verification",
"variables": { "code": "4592" }
}'const res = await fetch("https://zindua.run/api/v1/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.ZINDUA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: "+243812345678",
channel: "whatsapp",
template: "otp-verification",
variables: { code: "4592" },
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const data = await res.json();
console.log(data);- 202Accepted. Message queued (or sent in test mode).
- 400Invalid body (missing to/template, bad email or phone format).
- 401Missing or invalid API key.
- 403Plan limit, origin not allowed (browser), or subscription issue.
- 404Template slug not found for this project.
- 422WhatsApp not connected, paused, or template missing WhatsApp body.
- 429Rate limit. Retry after retryAfterSec (WhatsApp).
# Example error (401)
{
"error": "Missing or invalid API key. Use: Authorization: Bearer znd_live_xxx"
}Examples
Default template language is English. Developers can send with lang fr or es after creating those template versions in the dashboard.
Next.js OTP demo
Client pages call backend route handlers. The SDK stays server-side while users test email and WhatsApp OTP from a realistic login flow. Or scaffold with: npx @zindua/create-app@latest
Try in 60 seconds
- 1.Open http://localhost:3010 and enter email or phone (+243...).
- 2.Click Send OTP by email or Send OTP by WhatsApp.
- 3.Keep lang as en by default, or switch to fr/es after creating those template versions.
ZINDUA_API_KEY=znd_test_xxxxxxxxxxxxxxxxxxxxxxxx
ZINDUA_APP_NAME=LoginDemo
# Slug from Dashboard -> Templates (e.g. otp-verification)
ZINDUA_TEMPLATE_SLUG=otp-verificationInstall
$ npm install
added 180 packages in 4s
Run app
$ npm run dev
ready - started server on http://localhost:3010
Quick OTP test
$ curl -s -X POST http://localhost:3010/api/auth/send-otp -H "Content-Type: application/json" -d '{"channel":"whatsapp","to":"+243812345678","lang":"en"}'
{"ok":true,"channel":"whatsapp","to":"+243812345678"}Dashboard prerequisites
- •Create a project and copy API key from Dashboard -> Projects.
- •Create template slug otp-verification with default language set to en and token {{code}}.
- •Add optional template versions for fr and es if you want localized OTP copy.
- •Connect WhatsApp line (QR) to test WhatsApp OTP.
- •Connect an email service to test email OTP.
Step by step
- 1.Open http://localhost:3010 and enter email or phone (+243...).
- 2.Click Send OTP by email or Send OTP by WhatsApp.
- 3.Keep lang as en by default, or switch to fr/es after creating those template versions.
- 4.Enter the 6-digit OTP on /verify and submit.
- 5.Check /success page and dashboard logs for delivery status.
How Zindua Works
Four steps from dashboard to delivery on email or WhatsApp.
Connect channels
Email: Dashboard → Services (Gmail, Outlook, SMTP). WhatsApp: scan QR under Dashboard → WhatsApp. Messages go out from your accounts.
Create templates
One slug per template. Email body is HTML; WhatsApp body is short text. Use {{variables}} on both channels.
Send from code
POST /api/v1/send with channel email or whatsapp. Same API key, same logs. Free plan starts with WhatsApp OTP only.
Track delivery
View logs in the dashboard. Webhooks fire for email.delivered and email.failed when configured.
Quickstart
Start with HTTP / cURL (works everywhere), then pick a framework. Click the top bar to jump.
Next.js OTP starter
Scaffold a full login flow with email + WhatsApp OTP. API key stays server-side.
npx @zindua/create-app@latest my-appZindua CLI
Send OTP tests and diagnose your API key from the terminal. Works with any stack.
npx @zindua/cli@latest doctorCLI
Send OTP tests, inspect your project, list templates, and diagnose your API key from the terminal. No code required.
npx @zindua/cli@latest doctorReads ZINDUA_API_KEY from env or .env.local. Never pass the key as a positional argument.
Diagnose API key
Checks key format, GET /project, and email/WhatsApp channel readiness.
npx @zindua/cli@latest doctorSend OTP test
Queue a message via your connected channels. Add --json for CI scripts.
npx @zindua/cli@latest send --to user@example.com --template otp-verification --var code=482910Inspect project
Project name, plan, API key suffix, channel status.
npx @zindua/cli@latest projectList templates
Synced template slugs, languages, and variables.
npx @zindua/cli@latest templates list# Set key once
export ZINDUA_API_KEY=znd_test_your_key
npx @zindua/cli@latest doctor
npx @zindua/cli@latest send --to user@example.com --template otp-verification --var code=482910
npx @zindua/cli@latest project
npx @zindua/cli@latest templates listHTTP / cURL
Full integration guide
# Any HTTP clientexport ZINDUA_API_KEY="znd_live_xxxxxxxxxxxxxxxxxxxx"# https://zindua.run/api/v1/send
# Authorization: Bearer znd_live_xxxcurl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer $ZINDUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "user@example.com",
"channel": "email",
"template": "welcome",
"variables": { "name": "Alex" }
}'curl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer $ZINDUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+243812345678",
"channel": "whatsapp",
"template": "otp-verification",
"variables": { "code": "4592" }
}'curl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer $ZINDUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+243812345678",
"channel": "whatsapp",
"template": "otp-verification",
"lang": "fr",
"variables": { "code": "4592" }
}'Next.js
Full integration guide
npm install @zindua/sdk# .env.local — one key per Zindua project (server only)
ZINDUA_KEY_MELLIA=znd_live_xxxxxxxxxxxxxxxxxxxxxxxx
ZINDUA_KEY_OVERLOOK=znd_live_yyyyyyyyyyyyyyyyyyyyyyyy// lib/zindua.ts
import { Zindua } from '@zindua/sdk';
export const zinduaByTenant = {
mellia: new Zindua({ apiKey: process.env.ZINDUA_KEY_MELLIA! }),
overlook: new Zindua({ apiKey: process.env.ZINDUA_KEY_OVERLOOK! }),
};// Pick tenant → project key (mellia | overlook)
await zinduaByTenant.mellia.send({
to: 'user@example.com',
template: 'welcome',
variables: { name: 'Alex' },
});await zinduaByTenant.overlook.send({
to: '+243812345678',
channel: 'whatsapp',
template: 'otp-verification',
variables: { code: '4592', app: 'MonApp' },
});await zinduaByTenant.overlook.send({
to: '+243812345678',
channel: 'whatsapp',
template: 'otp-verification',
lang: 'fr',
variables: { code: '4592' },
});
// Errors: import { ZinduaError } from '@zindua/sdk'
// catch (e) { if (e instanceof ZinduaError) console.log(e.code, e.status) }React
Full integration guide
npm install @zindua/sdkZINDUA_API_KEY=znd_live_xxxxxxxxxxxxxxxxxxxx// server/zindua.ts (backend only)
import { Zindua } from '@zindua/sdk';
export const zindua = new Zindua({
apiKey: process.env.ZINDUA_API_KEY!,
});// pages/api/notify.ts
import { zindua } from '@/server/zindua';
export default async function handler(req, res) {
const { email, template, variables } = req.body;
const result = await zindua.send({ to: email, template, variables });
res.status(202).json(result);
}// Client calls YOUR backend, not Zindua:
await fetch('/api/notify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
phone: '+243812345678',
template: 'otp-verification',
variables: { code: '482910' },
}),
});// Backend maps phone → Zindua send:
await zindua.send({
to: phone,
channel: 'whatsapp',
template: 'otp-verification',
lang: 'fr',
variables: { code },
});Python
Full integration guide
pip install requestsexport ZINDUA_API_KEY=znd_live_xxxxxxxxxxxxxxxxxxxximport os
import requests
API_URL = "https://zindua.run/api/v1/send"
API_KEY = os.environ["ZINDUA_API_KEY"]response = requests.post(
API_URL,
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"to": "user@example.com",
"channel": "email",
"template": "reset-password",
"variables": {"name": "Sarah", "link": "https://..."},
},
timeout=30,
)
response.raise_for_status()
print(response.json())response = requests.post(
API_URL,
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
json={
"to": "+243812345678",
"channel": "whatsapp",
"template": "otp-verification",
"variables": {"code": "4592"},
},
timeout=30,
)
response.raise_for_status()requests.post(
API_URL,
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
json={
"to": "+243812345678",
"channel": "whatsapp",
"template": "otp-verification",
"lang": "fr",
"variables": {"code": "4592"},
},
timeout=30,
).raise_for_status()Flutter
Full integration guide
# pubspec.yaml
dependencies:
http: ^1.2.0// Pass key from your backend. Never ship znd_live_ in the app// Call YOUR backend; it holds the Zindua API key.
// Direct Zindua calls from mobile are not recommended.// Your backend POST https://zindua.run/api/v1/send
// Body: { "to": "customer@example.com", "template": "order-shipped", ... }// Your backend:
// { "to": "+243812345678", "channel": "whatsapp", "template": "otp-verification", ... }await http.post(
Uri.parse('https://api.yourapp.com/v1/notify'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'phone': '+243812345678',
'template': 'otp-verification',
'lang': 'fr',
'code': '4592',
}),
);React Native
Full integration guide
# Use your backend. No Zindua key in the appZINDUA_API_KEY=znd_live_... # server .env only// Node/Express backend with @zindua/sdk or HTTP POST https://zindua.run/api/v1/send// Backend:
await zindua.send({
to: email,
channel: 'email',
template: 'verify-email',
variables: { link },
});await zindua.send({
to: phone,
channel: 'whatsapp',
template: 'otp-verification',
variables: { code },
});await fetch('https://api.yourapp.com/send-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '+243812345678', code, lang: 'fr' }),
});Templates
One slug for email and WhatsApp. Update copy in the dashboard without redeploying your app.
How templates work
Create a template in Dashboard → Templates and set a slug (e.g. otp-verification).
Email: paste HTML with {{variables}}. WhatsApp: short plain text for OTP and alerts.
Add language versions if needed (fr, en, sw…).
Call send() with template slug + channel. Zindua renders variables per channel.
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; padding: 20px;">
<h1>Welcome, {{name}}!</h1>
<p>Thanks for joining {{appName}}.</p>
<a href="{{verifyUrl}}"
style="background: #f97316; color: white;
padding: 12px 24px; border-radius: 8px;
text-decoration: none; display: inline-block;">
Verify Your Email
</a>
</body>
</html>{{app}}: your verification code: {{code}}
This code expires in 10 minutes. Do not share it.Send OTP & codes on WhatsApp
Use the same API and templates as email. Set channel: "whatsapp" and a phone number in E.164 format. Your message is delivered from the number you connect in the dashboard.
Dashboard setup (before your first send)
Create a project and copy your API key (znd_live_… or znd_test_…).
Open Dashboard → your project → WhatsApp → Connect.
Scan the QR code with the phone you use for OTP (dedicated business line recommended).
Wait until status shows Connected. You can Pause sending or Unlink the number anytime.
Call POST /api/v1/send from your backend only. Never expose the API key in mobile or web clients.
Free plan: WhatsApp OTP only (200 messages/month). Pro and Team add the email API plus higher or unlimited WhatsApp OTP quotas.
Pause
Temporarily stop outbound WhatsApp from your number. API returns a clear error until you resume.
Unlink
Disconnect the session completely. Scan again to reconnect. Unlink does not delete your templates or logs.
Full examples: and Quickstart step 5 per framework.
curl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer $ZINDUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+243812345678",
"channel": "whatsapp",
"template": "otp-verification",
"variables": { "code": "4592" }
}'Plans & channels
Free starts with WhatsApp OTP. Upgrade for email API and higher WhatsApp quotas. Same POST /api/v1/send on every plan.
| Plan | Email API | WhatsApp quota | |
|---|---|---|---|
| Free | Yes | Yes | 200 / month |
| Pro | Yes | Yes | 20,000 / month |
| Team | Yes | Yes | Unlimited |
Free: Email API when a Service is connected on the project (Gmail/SMTP). WhatsApp via QR.
Pro: Email API + WhatsApp. Connect Gmail/SMTP and your WhatsApp line.
Team: Full channels for production scale.
Multilingual messages
Each template can have multiple language versions for email and WhatsApp. Zindua picks the right one from your send() call.
Default Language
Every project has a default language (Dashboard → Project Settings). When you call send() with optional lang, Zindua uses that template version for email or WhatsApp. If the version is missing, it falls back to the project default.
curl -X POST https://zindua.run/api/v1/send \
-H "Authorization: Bearer $ZINDUA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+243812345678",
"channel": "whatsapp",
"template": "otp-verification",
"lang": "fr",
"variables": { "code": "4592" }
}'Webhooks
HTTPS callbacks for delivery events. Configure URL and events under Dashboard → Settings → Integrations. Payloads are signed when WEBHOOK_SIGNING_SECRET is set on the server.
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "email.delivered",
"version": "2026-04-13",
"created": "2026-04-12T12:00:00.000Z",
"data": {
"logId": "uuid",
"projectId": "uuid",
"recipient": "user@example.com",
"messageId": "msg_482910"
}
}SDKs & HTTP API
Node/TypeScript SDK matches POST /api/v1/send. Other stacks use the REST endpoint from your backend.
Official Node.js SDK on npm
npm install @zindua/sdkOne template slug for email and WhatsApp. Add up to 3 languages per template on Free (5 templates per project); Pro and Team raise both limits. Pass lang in send() to pick the locale.
PHP SDK
Official PHP SDK for Laravel, Symfony, WordPress custom, and cron scripts.
composer require zindua/sdkPackagistZindua class with typed send(). Works in Next.js, Express, Remix, Hono, and Node 18+.
Terminal tools: send OTP tests, inspect project, list templates, diagnose API key.
zindua/sdk for Laravel, Symfony, WordPress custom. Server-side send() with validation.
Use requests/httpx against the send endpoint (see Quickstart → Python).
Mobile apps call your API; your server holds the znd_live_ key.
Same as Flutter: never embed the Zindua API key in the app binary.
Language-agnostic HTTP API. Use from Go, Ruby, PHP, Java, or anything with HTTP.
Use the Node SDK in your backend. Never expose API keys in client-side React.
Several clients, several API keys
Create one Zindua project per client (or per brand). Your application chooses the right key server-side. Never ship multiple keys to the browser.
| Zindua project | API key | Usage |
|---|---|---|
| Client A (e.g. mellia) | znd_live_… | Templates + Gmail/SMTP for client A |
| Client B (e.g. Overlook) | znd_live_… | Templates + Gmail/SMTP for client B |
- Separate quotas and logs per client
- Different Gmail/SMTP per project
- Revoke one key without affecting the other
One project + one key is fine when every customer shares the same sender, templates, and quota. For separate clients, prefer two projects.
# .env — server only, never in the front-end
ZINDUA_KEY_MELLIA=znd_live_xxxxxxxxxxxxxxxxxxxxxxxx
ZINDUA_KEY_OVERLOOK=znd_live_yyyyyyyyyyyyyyyyyyyyyyyyimport { Zindua } from "@zindua/sdk";
const zinduaByTenant: Record<string, Zindua> = {
mellia: new Zindua({ apiKey: process.env.ZINDUA_KEY_MELLIA! }),
overlook: new Zindua({ apiKey: process.env.ZINDUA_KEY_OVERLOOK! }),
};
export async function sendOtp(tenantId: "mellia" | "overlook", to: string, code: string) {
const client = zinduaByTenant[tenantId];
return client.send({
to,
template: "otp",
variables: { code },
});
}Security
Keep your integration safe. Here's what matters.
Never expose your API key
Use znd_live_ keys only on the server (env vars, secrets manager). Never in mobile apps, browsers, or public repos.
Authorization: Bearer only
Send znd_live_… in Authorization: Bearer. Never in URL (?api_key=), JSON body, or X-Api-Key — blocked by the API.
Backend-only send()
Your app calls your API route; your API route calls Zindua. The end user never sees Zindua credentials.
One key per project
Each Zindua project has its own key. A request only accesses that project's templates, service, and logs.
WhatsApp session stays on Zindua
After QR scan, the linked session is stored encrypted on our side. You only use your API key; you never receive session tokens.
CORS allowlist (browser)
Browser calls must match allowed origins under Dashboard → Settings → Integrations. Server-to-server calls without Origin are unaffected.
Verify webhook signatures
When WEBHOOK_SIGNING_SECRET is set on the server, validate X-Zindua-Signature (sha256=…) before trusting events.