What is in here
Six pages. Each one answers a question you actually have to settle before this is running and sending codes to real phones.
- Quickstart: Two HTTP calls, from a fresh install to a verified code. Copy-paste, no framework.
- WhatsApp OTP: The Meta Cloud API path: business verification, the authentication template, and the four things that stop a launch.
- Telegram OTP: The channel with no business verification, no card and no per-message fee. Bot setup and the linking flow.
- Self-hosting: Docker Compose, TLS, backups and upgrades. What production refuses to boot without, and why.
- API reference: Every endpoint, every field, every error code and the retry decision for each.
- FAQ: The questions that decide whether this fits your project, answered without hedging.
What this software is, and what it is not
Being precise here saves everyone a support thread. This project is a gateway: it sits between your application and a messaging provider you already have an account with. It is not a messaging provider, and it does not resell anything.
- You bring your own credentials.Your Meta WhatsApp Business account, your Telegram bot. Nothing is shared with the project’s authors, and no code here calls a service they operate.
- You pay the provider, not this project.WhatsApp messages are billed by Meta to you, at Meta’s rates. Telegram delivery is free. This software costs nothing either way.
- There is no hosted tier. No signup, no account, no usage dashboard belonging to anyone but you. You run the container and you hold the database.
- It is one process. One FastAPI worker, one PocketBase, one Postgres-free SQLite file. The docs say so explicitly because the in-memory locks and rate limiters assume it.
Hand this to your agent
The whole contract is one self-contained file, written to be pasted into a context window. It is generated from the same facts as the API reference, so the two cannot drift. Copy it, or point your agent at /agent-briefing.md directly.
# wotp — agent briefing
you are integrating an otp gateway. this file is the whole contract. read it
once, then write the integration. do not browse the site for more context —
everything you need is here, and anything not in this file does not exist.
## 1 · environment
two values, neither of which is in this file:
WOTP_API gateway base url, no trailing slash
WOTP_KEY an api key, prefixed wotp_
ask the user for both. never invent a host. never hardcode a key — put it in an
environment variable or a secret manager.
## 2 · the whole integration
POST $WOTP_API/v1/otp/send
X-Api-Key: $WOTP_KEY
Content-Type: application/json
{ "to": "919876543210", "channel": "whatsapp" }
channel optional "whatsapp" (default) | "telegram"
code optional custom code, 4-10 chars [A-Za-z0-9]
200 { "ok": true, "channel": "whatsapp",
"request_id": "m8f3k2m9xq01zb4",
"message_id": "wamid.XXXX",
"expires_in": 300, "used": 42, "limit": 500,
"reset_utc": "2026-10-01T00:00:00Z" }
POST $WOTP_API/v1/otp/verify
X-Api-Key: $WOTP_KEY
Content-Type: application/json
{ "to": "919876543210", "code": "123456" }
200 { "ok": true, "verified": true }
400 { "ok": false, "verified": false,
"error": "wrong_code", "attempts_left": 2 }
that is the entire surface. build the ui timer from `expires_in` in the send
response — do not hardcode 300.
`to` is lenient: `+`, spaces and dashes are ignored, a bare national number is
read with the installation's default country code (india `+91` unless the
operator changed it), and a leading trunk `0` is dropped. normalized form is
digits + country code.
GET $WOTP_API/v1/otp/usage -> { "used", "limit", "reset_utc" }
## 3 · limits — read them from responses, never hardcode
monthly sends 500 whatsapp, for the whole installation, utc calendar month
telegram never metered against that monthly cap
per phone 5 sends / hour, both channels
verify attempts 3 per code, then the code is destroyed
code ttl 300 s
requests 10 / min per key, all endpoints combined
active keys 5
these are the installation's own defaults and the operator can change them.
`limit`, `expires_in` and every `Retry-After` in a real response win over this
list. `limit` is `0` when the operator has set no monthly cap at all.
failed sends never consume quota or throttle, but they are always logged.
## 4 · verify behaviour that breaks naive code
single-use a code dies the instant it verifies. re-verifying it, even
correctly and immediately, returns code_expired.
latest only verify checks the newest code for that phone. sending twice
silently kills the first code. never send twice for one screen.
per code the 3-attempt budget belongs to the code, not the phone. a new
code means a fresh 3.
## 5 · errors, and what to do about each
400 invalid_request fix the request; retrying unchanged changes nothing
detail: an array of field errors, or a string
401 invalid_api_key key missing or unknown; missing_header:true if absent
403 key_disabled key deactivated, or the account is suspended
404 key_not_found unknown key id on deactivate
409 user_not_linked telegram first contact; show link_url as a "connect
telegram" button, then RETRY THE SEND. cost nothing,
stored no code, consumed no quota
409 key_limit_reached 5 active keys already; deactivate one
429 quota_exceeded the installation's monthly whatsapp cap; sleep
Retry-After (min 60), or move traffic to telegram,
which is not metered against that cap
429 phone_throttled more than 5 sends to one phone in the trailing hour;
sleep Retry-After (3600)
429 rate_limited more than 10 req/min on this key; sleep Retry-After
(60)
502 delivery_failed read `retryable`:
true -> transient. retry with backoff.
false -> permanent. DO NOT RETRY. the number is not
on whatsapp, or the telegram chat is
blocked. tell the user.
503 not_configured operator has not set up this channel; not fixable
from your side, so use the other channel
503 upstream_unavailable store is down; retry with exponential backoff
500 internal_error retry once with backoff, then stop
sleep exactly the value in the `Retry-After` header. never guess a backoff.
never retry a 4xx. do retry a 503.
error bodies are `{ "ok": false, "error": "<code>" }` plus the optional extras
named above.
## 6 · rules
- call the api from the user's BACKEND only. never from a browser. a key in
client javascript is public, and CORS is locked to the dashboard origin,
so browser calls fail anyway.
- keep the key in an env var or secret manager. never in source, logs, or a
client bundle.
- never log an otp code, sent or received.
- trust only the api's { "ok": true, "verified": true }. never a client-side
"the user says they got it".
- give the http client a timeout of at least 30 s. the gateway itself waits
up to ~15 s on the provider.
- keep `request_id` for sends you care about; it locates the message in the
audit log.
- do not send a custom `code` unless asked. the generated 6-digit code is the
safer default, and a code derived from user data is guessable.
## 7 · testing without credentials
if the operator runs the gateway with `WOTP_MOCK_DELIVERY=1`, delivery is faked
while every response, error, quota and throttle behaves identically — and codes
are really stored. send a custom code and verify it back:
POST /v1/otp/send { "to": "919876543210", "code": "citest1" }
POST /v1/otp/verify { "to": "919876543210", "code": "citest1" }
`message_id` values start with `mock-` in this mode. `GET /v1/health` reports
the mode: `{ "ok": true, "pb": true, "mock_delivery": false }`.
## 8 · done when
[ ] key and base url come from env vars, not source
[ ] send + verify both run server-side
[ ] ui timer reads expires_in from the send response
[ ] the 4xx errors above are handled explicitly, and no 4xx is retried
[ ] Retry-After is honoured on 429
[ ] retryable:false on 502 stops the retry rather than looping
[ ] the target project's type checker and tests pass
more detail, with every field explained: /docs
Where to go next
If you want to see the wire format before installing anything, run the calls in your browser. It behaves like the gateway because it runs the same documented rules, and it sends nothing anywhere. When you are ready for a real one, self-hosting is about ten minutes.