v0.1 LIVE · Ed25519 per-message · SMS / email / push / RCS

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).

1. Sign the message body (server-side)
// 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();
2. Send 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 });
3. Consumer verifies (browser, Android messages app, your own app)
<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.

📝
Quickest

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.

🌐
Consumer side

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.

🖥️
Business side

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.

🔌
Any language

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.

⚠ Never ship your API key in browser JS

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.

HTML script tag
<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.

Initialize
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 compact tag is strict

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)

⚠ Not yet shipped

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.

Planned shape — DO NOT copy-paste yet
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).

ℹ Why not self-sign?

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

pure fetch, no SDK needed
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

stdlib only, no dependency
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

requests (or httpx — same shape)
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.

POST /v1/sign/batch — up to 1000 messages
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

POST /v1/businesses ADMIN

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

POST /v1/sign API KEY

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

POST /v1/sign/batch API KEY

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

POST /v1/verify OPEN

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"
}
ℹ valid:false is still HTTP 200

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

GET /v1/registry OPEN

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)

POST /v1/applications OPEN

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

GET /health OPEN

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]
FieldTypeDescription
bidbiz_ + 32 hexBusiness ID — lookup key in the public registry
sighex · 128 charsEd25519 signature (64 bytes) over the canonical signing string
midhex · 16 charsPer-message ID — random, 8 bytes. Prevents replay identification.
chenumChannel: 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

ℹ One extra SMS segment, worst case

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

StatusErrorMeaning
400invalid_requestJSON malformed or required field missing / out-of-range
400unsupported_channelchannel not one of sms/email/push/rcs
400content_too_longContent exceeds 10KB — split before signing
400batch_too_largeBatch request has more than 1000 messages
401invalid_api_keyMissing / malformed Authorization header
401revoked_api_keyKey was revoked — apply for a new one
403channel_not_allowedYour business is not approved for this channel yet
404unknown_businessbusiness_id in verify call is not in the registry
422signature_mismatchSignature did not verify against the claimed business's public key
422missing_tagCompact tag missing bid / sig / mid / ch
429rate_limitedQuota exceeded — see X-RateLimit headers & Retry-After
500internal_errorSomething broke on our side; check the status page

Rate limits & plans

Free

$0/mo
1,000 signs/day
  • 1 registered business
  • SMS channel only
  • Community support
  • All SDKs + REST
  • Listed in public registry

Enterprise

Custom
10M+ signs/day, BYOK HSM
  • 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

ℹ Before you flip the production flag

Every item on this list is a real failure mode we've seen in integrations.

Support

© 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.