ADR-012: Leads are stored on our server and published through an outbox
Who is this for
Section titled “Who is this for”Developers and the owner — read this before changing how a request travels from the form to the sales group.
POST /leadsonly 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.
Context
Section titled “Context”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).
Decision
Section titled “Decision”| 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_atis empty;next_publish_atsays 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_idwhen 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-Secretheader, 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
sendMessageas 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).
Alternatives considered
Section titled “Alternatives considered”| 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. |
Consequences
Section titled “Consequences”- 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_idappear 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.
Evidence
Section titled “Evidence”docs/LEADS_TZ.md, sections 2, 3, 5, 9.- Landing side:
src/lib/deliverRequest.ts. - Commits
1d8620e,6d1555f(product) and23c6c8b(landing).
Where to go next
Section titled “Where to go next”- Lead submission flow.
- Playbook: new product — reusing this design.
- Decisions index.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.