Lead submission
Who is this for
Section titled “Who is this for”Developers of the landing page or the leads module, and whoever answers “a request was sent but nobody saw it”.
- The landing form posts to a Next.js server action. The action sends the request to
POST /leadson the product server with a shared secret and a 5-second timeout. - The API only stores the lead in SQLite and answers. The bot process publishes the card to the Telegram group from an outbox, so a stopped bot delays the card but does not lose the lead.
- If the leads server is unreachable or misconfigured, the landing falls back to a direct Telegram
sendMessagewith a warning first line. Such a request is not in the database. - The same
request_idtwice gives200with the same lead number. The same Telegram username or phone within 24 hours becomes a repeat: no new lead, the existing card is rewritten and pinged. 429and422from the server never trigger the fallback.
Checks on the landing side
Section titled “Checks on the landing side”Order in
submitRequest.ts:
| # | Check | Result when it trips |
|---|---|---|
| 1 | Honeypot: hidden field company is not empty |
Returns success, sends nothing |
| 2 | Minimum fill time: startedAt is set and less than 3000 ms ago (MIN_FILL_MS) |
Returns success, sends nothing |
| 3 | Schema (requestSchema.ts): name 2–80 chars, Telegram @name, phone + and 7–15 digits, optional date ≤ 60, guests ≤ 40, comment ≤ 1200 |
invalid with per-field error codes |
| 4 | Rate limit (rateLimit.ts): 5 submissions per IP per 10 minutes, in memory. The IP is the first value of x-forwarded-for |
throttled |
The form creates one requestId (UUID v4) when it opens and sends it with every retry. If the value is missing or
not a UUID v4, the server action creates one. After a success the browser stores a timestamp in localStorage and
shows the “sent” screen instead of the form for 24 hours
(requestSent.ts). That is
a convenience, not a control.
Branch 1 — success
Section titled “Branch 1 — success”sequenceDiagram
autonumber
participant V as Visitor
participant L as Landing server action
participant A as API POST /leads
participant D as SQLite
participant B as Bot outbox loop
participant T as Leads group
V->>L: Submit form
Note over L: honeypot, fill time, schema, per-IP limit
L->>A: POST /leads with secret header, 5 s timeout
Note over A: secret, size, fields, request_id, limits
A->>D: insert lead, published_at empty
A-->>L: 201 with lead number
L-->>V: Sent screen
loop every 2 s
B->>D: leads without a card whose retry time has come
B->>T: sendMessage, card with status buttons
B->>D: published_at, chat_id, message_id
end
Steps on the server, in
leads_api.py:
- Module switched off (
LEADS_ENABLED=0or emptyLEADS_SECRET) →404. - Caller IP already has 5 wrong-secret attempts in 10 minutes →
429(why: secret). - Header
X-Stickers-Leads-Secretcompared withhmac.compare_digest→401on mismatch, and the failure is counted. - Body larger than 16 KB →
413. Not a JSON object →422. - Field validation (
services/leads.pyvalidate), same rules and error codes as the landing →422witherrors. - Known
request_id→200withduplicate: true. This check runs before the rate limits, so a landing retry is never throttled. - The visitor IP sent by the landing (
client_ip) is turned intoip_hash= HMAC-SHA256 withLEADS_IP_SALT. The raw IP is not stored. - Per-visitor limit by
ip_hash(LEADS_IP_LIMIT5 perLEADS_IP_WINDOW_SEC600) →429(why: visitor). - Global limit (
LEADS_HOURLY_LIMIT60 per hour) →429(why: hourly) and an admin alert, at most once per hour. db.lead_submitin oneBEGIN IMMEDIATEtransaction decides: new lead (201), repeat (200) or duplicate from a race (200).
The bot side is publish_due in
leads_bot.py. The card is HTML;
everything the visitor typed goes through html.escape. With LEADS_CARD_PII=0 the card hides name, username, phone
and comment.
Branch 2 — fallback
Section titled “Branch 2 — fallback”sequenceDiagram
autonumber
participant V as Visitor
participant L as Landing server action
participant A as API POST /leads
participant TG as Telegram Bot API
participant T as Leads group
V->>L: Submit form
L->>A: POST /leads, 5 s timeout
alt timeout or network error
A--xL: no answer
else 5xx, 401, 404 or another unexpected status
A-->>L: error status
end
L->>TG: sendMessage with warning first line, 10 s timeout
alt Telegram accepted
TG->>T: Plain-text message, no buttons
L-->>V: Sent screen
else Telegram failed too
L-->>V: Error, form stays filled
end
- The warning line is
⚠️ Запасной канал: сервер заявок не ответил — заявки нет в базе (request_id …)(“Backup channel: the leads server did not answer — the request is not in the database”). - The message is plain text built by
requestMessage.ts; the username becomes a link through an explicit entity. It has no status buttons, no number, and does not appear in statistics. An admin handles it by hand. - A possible double: the server stored the lead but its answer missed the 5-second window. The group then shows a
card and a fallback message with the same
request_id. - Without
LEADS_ENDPOINTandLEADS_SECRET(preview deployments, local runs) the action skips the server and sends straight to Telegram, without the warning line. Without bot variables as well, the request is printed to the server console and the visitor still sees “sent”.
Branch 3 — duplicate and repeat
Section titled “Branch 3 — duplicate and repeat”sequenceDiagram
autonumber
participant L as Landing server action
participant A as API POST /leads
participant D as SQLite
participant B as Bot outbox loop
participant T as Leads group
L->>A: POST /leads
alt same request_id as a stored lead or repeat
A-->>L: 200, duplicate true, same lead number
else same username or phone within 24 h
A->>D: insert lead_repeats row with changed fields only
A-->>L: 200, duplicate true, repeat true
B->>D: repeats not yet notified, lead already published
B->>T: editMessageText, card gains a repeat block
B->>T: Short ping as a reply to the card
B->>D: notified_at
else new contact
A->>D: insert lead
A-->>L: 201
end
Repeat rule (db.lead_submit in
db/db.py): an existing lead matches
when it was created within the last 24 hours (REPEAT_WINDOW), is not spam, is not anonymised, and has the same
Telegram username (case-insensitive) or the same phone. The repeat row stores the time and only the fields that
changed. The card shows up to 3 repeats, newest first, under 🔁 Повторное обращение: <time> (“Repeat request”). The
ping text contains no personal data. A repeat that arrives before the card is published is included in the first
version of the card and causes no ping. A repeat for a lead that became spam or was anonymised is only marked as
handled.
Status code mapping
Section titled “Status code mapping”How
deliverRequest.ts
reads the server’s answer:
| Server answer | Meaning on the server | Landing route | Visitor sees |
|---|---|---|---|
201 |
New lead stored | server |
Sent |
200 |
Same request_id, or a repeat |
server |
Sent |
429 |
Wrong-secret lockout, per-visitor limit or hourly limit | server-throttled, no fallback |
“Too many requests” |
422 |
Fields rejected: landing and server disagree | server-rejected, no fallback, logged |
Error |
401, 404 |
Secret wrong, or module off | fallback |
Sent if Telegram accepted |
413, 5xx, any other status |
Server problem | fallback |
Sent if Telegram accepted |
| Timeout (5 s) or network error | Server unreachable | fallback |
Sent if Telegram accepted |
| Fallback also failed | — | lost |
Error, form stays |
The server action logs one line per request: [request] <request_id> → <route>.
Outbox retry schedule
Section titled “Outbox retry schedule”| Failure number for one lead | Next attempt after |
|---|---|
| 1 | 5 s |
| 2 | 15 s |
| 3 | 60 s |
| 4 | 5 min |
| 5 and later | 15 min, and an admin alert (at most once per 30 minutes) |
Telegram flood control (retry_after) |
Exactly the time Telegram asks for; the rest of the batch waits too |
The loop takes up to 10 due leads per pass and runs every 2 seconds (PUBLISH_INTERVAL_SEC). A lead that cannot be
published stays visible through /stickers_new. Repeat notifications use the same delays and give up after 5 failed
attempts.
Where to go next
Section titled “Where to go next”- Lead status change — what happens when someone presses a button on the card.
- Reminders and digest — the once-a-minute housekeeping.
- Landing component — the form and its environment variables.
- Runbook: leads missing or fallback — operator steps.
- Lead process — how the team works with the cards.
Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.