Skip to content

Lead submission

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 /leads on 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 sendMessage with a warning first line. Such a request is not in the database.
  • The same request_id twice gives 200 with 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.
  • 429 and 422 from the server never trigger the fallback.

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.

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:

  1. Module switched off (LEADS_ENABLED=0 or empty LEADS_SECRET) → 404.
  2. Caller IP already has 5 wrong-secret attempts in 10 minutes → 429 (why: secret).
  3. Header X-Stickers-Leads-Secret compared with hmac.compare_digest → 401 on mismatch, and the failure is counted.
  4. Body larger than 16 KB → 413. Not a JSON object → 422.
  5. Field validation (services/leads.py validate), same rules and error codes as the landing → 422 with errors.
  6. Known request_id → 200 with duplicate: true. This check runs before the rate limits, so a landing retry is never throttled.
  7. The visitor IP sent by the landing (client_ip) is turned into ip_hash = HMAC-SHA256 with LEADS_IP_SALT. The raw IP is not stored.
  8. Per-visitor limit by ip_hash (LEADS_IP_LIMIT 5 per LEADS_IP_WINDOW_SEC 600) → 429 (why: visitor).
  9. Global limit (LEADS_HOURLY_LIMIT 60 per hour) → 429 (why: hourly) and an admin alert, at most once per hour.
  10. db.lead_submit in one BEGIN IMMEDIATE transaction 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.

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_ENDPOINT and LEADS_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”.
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.

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>.

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.


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