Skip to content

ADR-012: Leads are stored on our server and published through an outbox

Developers and the owner — read this before changing how a request travels from the form to the sales group.

  • POST /leads only validates, stores and answers. It never calls Telegram.
  • A loop in the bot process publishes unpublished leads every 2 seconds and retries with growing pauses.
  • A lead stored while the bot is down is published when the bot comes back.
  • Status: accepted.

Originally the landing form sent each request straight into a Telegram group with sendMessage. Requests were not stored anywhere, had no status and no statistics, and a Telegram hiccup could lose one.

The server already runs two processes: the API behind Caddy, and the bot, which is the only process that receives Telegram updates (button presses and commands arrive through its polling).

What Where Why
Validate, store, answer sticker-api (bot/leads_api.py) Caddy already proxies to it; the answer comes right after the insert.
Publish the card, retry sticker-bot, outbox loop (bot/leads_bot.py) One sender to Telegram; survives restarts because the queue is the table.
Buttons and commands sticker-bot, leads router (bot/handlers/leads.py) Callbacks and commands only reach the polling process.
Reminders, digest, retention sticker-bot, the same loop once a minute Same reason.
  • The outbox is the rows where published_at is empty; next_publish_at says when to try again. Retry pauses are 5 s, 15 s, 1 min, 5 min, then every 15 minutes; a Telegram 429 is honoured for as long as it asks. After five failures an alert is sent.
  • Idempotency: the form generates a UUID request_id when it opens; a repeat with the same id returns the existing lead number and creates nothing.
  • Authentication: a shared secret in the X-Stickers-Leads-Secret header, compared in constant time, with a lockout after five wrong attempts from one caller. Without a configured secret the endpoint answers 404.
  • The visitor’s IP is never stored — only an HMAC of it with a server-side salt, for rate limiting.
  • The landing page keeps its direct sendMessage as a fallback channel for timeouts, network errors, 5xx and 401/404. It is deliberately not used for 429 (it would bypass the server’s limit) or 422 (a rules mismatch is a bug to fix, not to hide).
Alternative Why not
Keep posting straight to Telegram No storage, statuses or statistics.
Publish from the API with a background task The task is lost if the API restarts, and the API should not hold minute-long retries.
An external CRM or form service Another account and another place where personal data lives; the group workflow already works for the team.
  • If the server accepts a request but its answer is lost, the fallback also posts a message: a card and a fallback message with the same request_id appear side by side. This needs a network failure between two fast calls.
  • Fallback requests are not in the database, have no buttons and are missing from statistics; someone must handle them by hand.
  • The leads loop must never raise: in the bot process a crashed task stops the whole bot. The loop catches its own errors, and a test asserts it.
  • Lead contact data now lives in the same database and backups as guest metadata — see Privacy.

Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.