docs · v0.1.0

read it before you run it.

This is a two-call OTP gateway you host yourself, on your own Meta and Telegram credentials. The docs are short because the surface is short. Start with the quickstart, or hand the briefing below to your agent.

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.

raw markdown
# 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.