Signed SMS & push. In 15 lines.
Stamp every SMS, email, push, and RCS your business sends with an Ed25519 signature. Your customers (or their apps) verify the sender cryptographically — no lookalike domain, no spoofed header ID, no SS7 trick gets through. The full product pre-analysis lives at /about/verify (5-point threat model + why-this-and-not-DKIM).
Quick Start
Three moves to send your first signed SMS: sign the body on your server, concatenate the returned compact tag, ship through your existing SMS gateway (MSG91, Gupshup, Twilio, Kaleyra, whatever).
// Node.js — Zoza does the Ed25519, your API key authorises
const res = await fetch('https://verify-api.zoza.world/v1/sign', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VERIFY_KEY}`, // zv_...
'Content-Type': 'application/json'
},
body: JSON.stringify({
channel: 'sms',
content: 'Your OTP is 482913. Valid for 5 minutes.'
})
});
const { full_message, compact, message_id } = await res.json();
full_message through your existing SMS gateway// full_message = content + " " + compact
// e.g. "Your OTP is 482913. Valid for 5 minutes. [zoza:bid=biz_abc&sig=...&mid=...&ch=sms]"
await msg91.sendSMS({ to: '+919876543210', body: full_message });
<script src="https://cdn.zoza.world/verify/v1/verify.js"></script>
<script>
const zv = new ZozaVerifyConsumer('https://verify-api.zoza.world');
await zv.refreshRegistry();
const result = await zv.verifyMessage(pastedSms);
// result: { valid: true, business_name: "Example Bank", channel: "sms", verified: true }
</script>
That's the full round-trip. Everything past this point is production hardening: batch signing, registry caching, framework components, plan tiers, and the REST reference.
Pick Your Integration
Verify ships four integration surfaces. Business senders use one server SDK; consumer-side verification ships as a browser widget plus (planned) a React component.
HTML apply form
Post the apply form on this page. We review within 24h and email a zv_... API key on approval. You do not touch code until the key arrives.
Browser verify widget (verify.js)
Drop-in <script> tag. Users paste a suspicious SMS or forwarded message; the widget parses the compact tag, checks the signature against the cached registry, renders a verdict. Works offline after first registry sync.
Node.js / Go / Python signing SDK
Server-to-server call to POST /v1/sign. You keep your existing SMS gateway (MSG91, Twilio, Kaleyra); Verify only adds the signature tag and returns full_message ready to ship.
Plain REST
No SDK, no dependencies. curl-level HTTP/JSON. Useful for Go, Rust, Java, Elixir, or any stack where an SDK doesn't ship yet. All six endpoints documented below.
Apply for an API Key
Signing endpoints require a zv_... API key. The public verify + registry endpoints are keyless and open to anyone — consumer-side code never needs credentials. Submit the form below — we review within 24 hours and email your key on approval. Keys are shown once at issuance — store yours somewhere safe (they are not retrievable).
Authentication
Signing endpoints require your business's API key as a Bearer token:
Authorization: Bearer zv_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Key format: zv_ followed by 64 hex characters (32 bytes of entropy). The prefix is intentional so key-leak scanners (GitGuardian, GitHub secret scanning, TruffleHog) can flag accidental commits. Every authenticated response includes rate-limit headers:
X-RateLimit-Limit: 100000 # your daily quota
X-RateLimit-Remaining: 99872 # signs left today
X-RateLimit-Reset-UTC: 00:00 # quota resets at UTC midnight
If a key is invalid, revoked, or over quota, the response is 401 (invalid / revoked) or 429 (quota) with an error body explaining which of those it was.
Consumer-side code — the verify.js widget, any React component, mobile SDKs — never needs your key. Verification uses the public registry. Keep the zv_... key on your server, in an env var or secret manager. If you accidentally commit it, email us and we'll revoke within an hour.
Base URL
https://verify-api.zoza.world # production, direct
https://zoza-products.fly.dev/verify/* # gateway path (same handlers, longer URL)
All REST paths below are relative to the production base. The gateway path exists for when the dedicated subdomain is not yet resolvable (the form is designed to ship before DNS / cert is fully wired); both routes serve identical responses.
Browser SDK — Installation
The consumer-side widget parses a pasted or forwarded message, looks up the sender in a locally-cached registry, and verifies the Ed25519 signature. It never needs your API key — the registry is public.
<script src="https://cdn.zoza.world/verify/v1/verify.js"></script>
Zero runtime dependencies. ~9KB gzipped. Works in browsers (Chrome 110+, Safari 17+, Firefox 115+), Node ≥18, Deno, Bun. Ed25519 signature verification runs via the Web Crypto subtle API where available, with a pure-JS @noble/ed25519 fallback on older browsers.
const zv = new ZozaVerifyConsumer(
'https://verify-api.zoza.world' // default — override if you proxy
);
// Pull latest registry (signed payload, 5-minute cached edge response)
await zv.refreshRegistry();
The registry is a list of every approved business with its Ed25519 public key and allowed channels. Fetched once per session (or every 5 min) and stored in localStorage. All subsequent verifications are fully offline — useful for spotty connections, captive-portal scenarios, and privacy-conscious consumers who don't want their inbox content phoned home.
Verify a pasted message
Consumers paste the SMS (or the forwarded text) into your widget. verifyMessage auto-parses the [zoza:bid=...&sig=...&mid=...&ch=...] compact tag, looks up the business by bid, and checks the Ed25519 signature.
const pastedSms = 'Your OTP is 482913. Valid for 5 minutes. ' +
'[zoza:bid=biz_abc123&sig=deadbeef...&mid=0a1b2c3d&ch=sms]';
const result = await zv.verifyMessage(pastedSms);
// result = {
// valid: true,
// business_id: "biz_abc123...",
// business_name: "Example Bank Pvt. Ltd.",
// channel: "sms",
// verified: true, // business has cleared Zoza verification review
// message_id: "0a1b2c3d..."
// }
// If valid === false, result.error tells you why
// (e.g. "unknown_business", "signature_mismatch", "missing_tag", "channel_not_allowed").
The parser requires all four fields — bid, sig, mid, ch — in the [zoza:...] bracket. A malformed tag, a missing field, or extra unrecognised fields all fail closed with missing_tag. That way a bad actor cannot trick the parser by injecting [zoza:bid=...] alone.
Registry caching & offline verify
The widget fetches /v1/registry at construction (or the first refreshRegistry() call), stores it in localStorage under a namespaced key, and serves all verifications from the local cache. Re-fetches opportunistically on stale cache (>5 minutes) but still verifies against the cached copy if the network is down.
// Force refresh (e.g. on a "new message arrived" event)
await zv.refreshRegistry();
// Get the cached registry snapshot
const reg = zv.getRegistry();
// reg = { businesses: [{ business_id, business_name, domain, public_key, channels, verified }],
// count: 42, updated_at: "2026-04-17T10:22:15Z" }
// Clear cached registry (e.g. on sign-out)
zv.clearRegistry();
React component (planned v0.2)
No @zoza/verify-react npm package is published yet — the shape below is what the v0.2 component will look like. Until then, wrap the vanilla ZozaVerifyConsumer in a React useEffect yourself (5 lines). If you want early access, email hello@zoza.world.
import { VerifiedMessage } from '@zoza/verify-react'; // v0.2 (planned)
export function MessageReader({ text }) {
return (
<VerifiedMessage text={text}>
{({ valid, businessName, channel, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <Unverified reason={error} />;
return valid
? <Verified>Signed by {businessName} via {channel}</Verified>
: <Unverified reason="signature_mismatch" />;
}}
</VerifiedMessage>
);
}
Until the package ships, the vanilla SDK wrapped in a useEffect does the same job — the result object is stable across versions, so your integration code will not need to change.
Server SDKs — Signing
The signing side is dead simple: POST a channel + content pair, get back a full_message you feed into your existing SMS / email / push gateway. Your business's Ed25519 private key lives on Zoza's signing server — we rotate it on request, audit every use, and never expose it to you (that's the point of outsourcing the crypto primitive).
You could, technically, keep the private key yourself and POST only the signature. We opted for a managed signing service because (a) 95% of SMS senders don't have a Go/Node process that can hold an HSM-bound key reliably, and (b) per-business key rotation + revocation on compromise is an operational nightmare across 10 SDK versions. Enterprise customers with HSMs can ask for BYOK — contact us.
Node.js — signing
const VERIFY_KEY = process.env.VERIFY_KEY; // zv_...
const VERIFY_URL = 'https://verify-api.zoza.world';
async function signSMS(content) {
const res = await fetch(`${VERIFY_URL}/v1/sign`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${VERIFY_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ channel: 'sms', content })
});
if (!res.ok) throw new Error(`sign failed: ${res.status}`);
return (await res.json()).full_message;
}
// Usage: ship through whatever gateway you already use
const signed = await signSMS('Your OTP is 482913. Valid 5 min.');
await msg91.sendSMS({ to: '+91...', body: signed });
Go — signing
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func SignSMS(content string) (string, error) {
body, _ := json.Marshal(map[string]string{
"channel": "sms",
"content": content,
})
req, _ := http.NewRequest("POST",
"https://verify-api.zoza.world/v1/sign",
bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("VERIFY_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { return "", err }
defer res.Body.Close()
if res.StatusCode != 200 { return "", fmt.Errorf("sign: %d", res.StatusCode) }
var out struct { FullMessage string `json:"full_message"` }
json.NewDecoder(res.Body).Decode(&out)
return out.FullMessage, nil
}
Python — signing
import os, requests
VERIFY_URL = "https://verify-api.zoza.world"
HEADERS = {
"Authorization": f"Bearer {os.environ['VERIFY_KEY']}",
"Content-Type": "application/json",
}
def sign_sms(content: str) -> str:
r = requests.post(
f"{VERIFY_URL}/v1/sign",
headers=HEADERS,
json={"channel": "sms", "content": content},
timeout=10,
)
r.raise_for_status()
return r.json()["full_message"]
# Usage
signed = sign_sms("Your OTP is 482913. Valid 5 min.")
msg91.send_sms(to="+91...", body=signed)
Batch signing — one round-trip for N messages
For OTP blasts, transaction notifications, or any fan-out, sign up to 1000 messages per batch call. Cuts network round-trips by orders of magnitude; per-message Ed25519 signing itself is already sub-millisecond.
const res = await fetch('https://verify-api.zoza.world/v1/sign/batch', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VERIFY_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
messages: [
{ channel: 'sms', content: 'OTP 482913 · expires 5 min' },
{ channel: 'sms', content: 'OTP 173028 · expires 5 min' },
{ channel: 'sms', content: 'OTP 905217 · expires 5 min' }
// ... up to 1000
]
})
});
const { results } = await res.json();
// results = [{ message_id, compact, full_message, signed_at }, ...]
// Element order matches input order 1-to-1.
REST API
Full endpoint reference. All paths are relative to https://verify-api.zoza.world. The gateway base https://zoza-products.fly.dev/verify serves identical handlers — useful while DNS propagates or if you need a fallback host.
Register business
Create a new business with a fresh Ed25519 keypair. Returns the API key and public key once — store both safely. This endpoint is gated by the admin token; most customers arrive via the apply form instead, which admins approve in the Zoza dashboard (which triggers this call on their behalf).
curl -X POST https://verify-api.zoza.world/v1/businesses \
-H "Authorization: Bearer $ZOZA_VERIFY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Example Bank Pvt. Ltd.", "domain": "examplebank.com", "channels": ["sms", "email"]}'
# Response 201 — shown ONCE
{
"business_id": "biz_abc123...",
"business_name": "Example Bank Pvt. Ltd.",
"domain": "examplebank.com",
"channels": ["sms", "email"],
"api_key": "zv_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"public_key": "4f2c...a9",
"created_at": "2026-04-17T09:00:00Z",
"message": "Save your API key — it won't be shown again."
}
Sign a message
Sign one message body. Returns the compact signature tag and a full_message ready to hand to your SMS / email / push gateway. Rate-limit headers on every response.
curl -X POST https://verify-api.zoza.world/v1/sign \
-H "Authorization: Bearer zv_..." \
-H "Content-Type: application/json" \
-d '{"channel": "sms", "content": "Your OTP is 482913. Valid 5 min."}'
# Response 200
{
"message_id": "0a1b2c3d4e5f6789",
"channel": "sms",
"signature": "deadbeef...", # hex-encoded Ed25519 sig
"compact": "[zoza:bid=biz_abc&sig=dead...&mid=0a1b2c3d4e5f6789&ch=sms]",
"signed_at": "2026-04-17T10:22:15Z",
"full_message": "Your OTP is 482913. Valid 5 min. [zoza:bid=biz_abc&sig=dead...&mid=0a1b2c3d4e5f6789&ch=sms]"
}
Sign a batch
Sign up to 1000 messages in one round-trip. Response results array preserves input order exactly.
curl -X POST https://verify-api.zoza.world/v1/sign/batch \
-H "Authorization: Bearer zv_..." \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"channel": "sms", "content": "OTP 482913"},
{"channel": "sms", "content": "OTP 173028"}
]
}'
# Response 200
{
"count": 2,
"results": [
{
"message_id": "0a1b2c3d4e5f6789",
"channel": "sms",
"signature": "dead...",
"compact": "[zoza:bid=biz_abc&sig=dead...&mid=0a1b2c3d4e5f6789&ch=sms]",
"signed_at": "2026-04-17T10:22:15Z",
"full_message": "OTP 482913 [zoza:bid=biz_abc&sig=dead...&mid=0a1b2c3d4e5f6789&ch=sms]"
},
{
"message_id": "89abcdef01234567",
"channel": "sms",
"signature": "beef...",
"compact": "[zoza:bid=biz_abc&sig=beef...&mid=89abcdef01234567&ch=sms]",
"signed_at": "2026-04-17T10:22:15Z",
"full_message": "OTP 173028 [zoza:bid=biz_abc&sig=beef...&mid=89abcdef01234567&ch=sms]"
}
]
}
Verify a message
Public verification endpoint — no auth. Pass business_id, channel, content, message_id, and signature (hex). Returns whether the signature is valid and who signed. The browser widget can call this directly as a fallback to offline registry verify.
curl -X POST https://verify-api.zoza.world/v1/verify \
-H "Content-Type: application/json" \
-d '{
"business_id": "biz_abc123",
"channel": "sms",
"content": "Your OTP is 482913. Valid 5 min.",
"message_id": "0a1b2c3d4e5f6789",
"signature": "deadbeef..."
}'
# Response 200 — valid signature
{
"valid": true,
"business_id": "biz_abc123",
"business_name": "Example Bank Pvt. Ltd.",
"channel": "sms",
"business_verified": true,
"signed_at": "2026-04-17T10:22:15Z"
}
# Response 200 — invalid signature (still 200, valid=false)
{
"valid": false,
"business_id": "biz_abc123",
"business_name": "Example Bank Pvt. Ltd.",
"channel": "sms",
"business_verified": true,
"error": "signature_mismatch"
}
We reserve 4xx for "your request was malformed" and 5xx for "our server broke". A genuinely-spoofed message is a successful verification call with valid=false. This lets the consumer widget render a clean UI without conflating "server error" and "fraud detected".
Public registry
Full directory of approved businesses with public keys and allowed channels. Public. Cached with Cache-Control: public, max-age=300 (5 min). Used by the browser widget for fully-offline verification. CORS enabled.
curl https://verify-api.zoza.world/v1/registry
# Response 200
{
"businesses": [
{
"business_id": "biz_abc123",
"business_name": "Example Bank Pvt. Ltd.",
"domain": "examplebank.com",
"public_key": "4f2c...a9",
"channels": ["sms", "email"],
"verified": true
},
{
"business_id": "biz_def456",
"business_name": "Example Exchange",
"domain": "exampleexchange.com",
"public_key": "8d44...c1",
"channels": ["sms", "push"],
"verified": true
}
],
"count": 42,
"updated_at": "2026-04-17T10:22:15Z"
}
Submit application (apply for key)
Submit a B2B customer application for an API key. Public endpoint — the apply form above posts to this. Rate-limited to 3/email/day and 10/IP/day.
curl -X POST https://verify-api.zoza.world/v1/applications \
-H "Content-Type: application/json" \
-d '{
"company": "Example Bank Pvt. Ltd.",
"email": "security@example.com",
"website": "https://example.com",
"use_case": "wallet",
"use_case_details": "Sign every OTP and transactional SMS we blast through MSG91.",
"volume_tier": "100k-1m",
"plan_requested": "pro"
}'
# Response 201
{
"id": "apl_abc123...",
"status": "pending",
"created_at": "2026-04-17T10:45:00Z",
"message": "Application received. We email you within 24h."
}
Health
Liveness probe for uptime monitors. Returns 200 {"status":"ok"} when the signing process is up and the Ed25519 key is loaded.
Compact tag format
The tag appended to every signed message. Strict — the parser enforces all four fields in this exact order-independent key=value grammar.
[zoza:bid=biz_abc123&sig=deadbeef...&mid=0a1b2c3d4e5f6789&ch=sms]
| Field | Type | Description |
|---|---|---|
| bid | biz_ + 32 hex | Business ID — lookup key in the public registry |
| sig | hex · 128 chars | Ed25519 signature (64 bytes) over the canonical signing string |
| mid | hex · 16 chars | Per-message ID — random, 8 bytes. Prevents replay identification. |
| ch | enum | Channel: sms, email, push, rcs |
Canonical signing string
signing_string = channel || "\x00" || message_id || "\x00" || content
signature = Ed25519-Sign(business_private_key, signing_string)
The \x00 separators prevent field-confusion attacks — no clever-casing of channel + content can produce a valid message_id + content. The content is signed in its exact pre-compact form, without the tag — otherwise the signature would be self-referential.
SMS segment impact
The compact tag is ~140 characters (7-bit GSM). A short OTP ("Your OTP is 482913. Valid 5 min." = 34 chars) fits with the tag into roughly 175 chars — that crosses the 160-char single-segment boundary and becomes 2 concatenated segments. Most SMS gateway pricing is per-segment; budget +1 segment on signed messages.
For longer notification SMS (already 2+ segments), the tag usually fits into the existing final segment with no extra cost. Run your own body-length audit before rolling out at scale.
Error codes
| Status | Error | Meaning |
|---|---|---|
| 400 | invalid_request | JSON malformed or required field missing / out-of-range |
| 400 | unsupported_channel | channel not one of sms/email/push/rcs |
| 400 | content_too_long | Content exceeds 10KB — split before signing |
| 400 | batch_too_large | Batch request has more than 1000 messages |
| 401 | invalid_api_key | Missing / malformed Authorization header |
| 401 | revoked_api_key | Key was revoked — apply for a new one |
| 403 | channel_not_allowed | Your business is not approved for this channel yet |
| 404 | unknown_business | business_id in verify call is not in the registry |
| 422 | signature_mismatch | Signature did not verify against the claimed business's public key |
| 422 | missing_tag | Compact tag missing bid / sig / mid / ch |
| 429 | rate_limited | Quota exceeded — see X-RateLimit headers & Retry-After |
| 500 | internal_error | Something broke on our side; check the status page |
Rate limits & plans
Free
- 1 registered business
- SMS channel only
- Community support
- All SDKs + REST
- Listed in public registry
Pro
- Unlimited businesses
- All channels (SMS/email/push/RCS)
- Batch endpoint (1000/call)
- Email support · 24h
- Domain-verified badge
Enterprise
- Dedicated subdomain
- Bring-your-own HSM key
- SLA + dedicated SRE
- Priority bug triage
- Custom channel approvals
Rate limits are per-API-key and reset at UTC midnight. Burst rate: 100 req/sec per key. Over quota returns 429 with a Retry-After header. Batch calls count as N individual signs against your quota (one sign/batch of 500 messages = 500 quota units).
CORS
The consumer-side endpoints — GET /v1/registry, POST /v1/verify, GET /v1/businesses/{id}/public-key, and POST /v1/applications — all return Access-Control-Allow-Origin: *. That's intentional: the browser widget and any third-party verifier (forwarded-SMS checker extensions, telco-side scam filters) must be able to call them from any origin.
The signing endpoints — POST /v1/sign and POST /v1/sign/batch — are server-to-server only. If you see a CORS error on /v1/sign, you're calling it from the wrong side of the network — the API key would be exposed in a browser bundle. Move the call behind your own backend.
Going-live checklist
Every item on this list is a real failure mode we've seen in integrations.
- Apply + wait. Submit the form above; on approval you get a single
zv_...key via email. Keys cannot be retrieved later — copy it into your secret manager the moment the email lands. - Store the key server-side only. Grep your build output — if
zv_appears in any bundled JS, rotate immediately. The consumer widget never needs your key; if you find yourself tempted to ship it, you've wired the architecture wrong. - Measure SMS-segment impact. Run a body-length audit on your existing message templates: any template currently at 120–155 chars will cross into a second segment once signed. Budget gateway cost accordingly, or trim boilerplate.
- Test tampered verification. Flip one character in the content or the signature of a signed message and paste into your widget — confirm it returns
valid=falsewithsignature_mismatch, not a silent pass. - Staging registry check. Before broadcasting to production users, call
GET /v1/registryand confirm your business ID appears withverified: trueand the correct channels. - Rate-limit plumbing. Handle
429with exponential backoff +Retry-After. For OTP flows, never block a user on a single sign call failure — fall back to an unsigned SMS with a clear "could not sign" marker before you lose the auth event. - Monitor valid=false volume. Pipe your consumer widget's verdicts to your analytics; an uptick in
signature_mismatchafter a key rotation means a stale client is still sending with the old key. - Audit-log access. Every sign call is logged with timestamp + message_id + channel. Pull it to your SIEM for fraud-team review. Export endpoints arrive in v0.2.
- No webhook yet. The incoming verify-event webhook (consumer paste → your backend notified) is planned v0.2, not shipped. Don't design around it today.
Support
- Docs: you're looking at them. Source lives in
frontend-web/public/developers/verify.html. - Status: status.zoza.world — incidents and maintenance windows.
- Security: security@zoza.world — crypto bugs, key-rotation requests, suspected signature-forgery reports.
- Product: hello@zoza.world — enterprise pricing, BYOK HSM, custom channel approvals, early access to the React component / webhook.
- Social: @zoza_world — release notes and outage comms.
- Product overview: full 5-point pre-analysis at /about/verify — why SMS/DKIM aren't enough, threat model, benchmarks vs competitors.
© 2026 Zoza. Source code copyright LD-16949/2026-CO. Client SDK open-source: @zoza/verify on npm + github.com/CoreCogitAI/verify-js-sdk. Backend source-available under NDA.