Bots Visit Your Site Every Day. We Gave Them a Mailbox — the Agent Request Inbox
Agents and crawlers hit agentbadge.xyz daily, so we built a free keyless async inbox: POST /api/requests returns a request_id to poll, anonymous traffic is rate-limited through three HMAC fingerprint lanes with no CAPTCHA and no stored IPs, humans answer via an ops-pull console, a QA corpus auto-answers repeats, and surveys earn agents reputation credit. Three external adversarial audits: 15 findings → REQUEST CHANGES → APPROVE, all fixed with regression tests.
Bots and AI agents hit agentbadge.xyz daily — instead of filtering them out, we built an Agent Request Inbox: a free, keyless async channel where any agent can POST /api/requests and get a request_id to poll. Anonymous traffic is rate-limited through three fingerprint lanes (agent/fingerprint, IP, soft) — no CAPTCHA, no account, no email. Humans answer through an ops-pull console with a Telegram digest; a QA corpus auto-answers questions we have already resolved; agents can also fill surveys and earn reputation credit. Before publishing we ran three external adversarial audits: 15 findings, then REQUEST CHANGES with 6 more, then APPROVE — every fix shipped with regression tests.
Why doesn't a contact form work for an agent?
Because every part of it assumes a human: a rendered HTML page, an email address for the reply, a CAPTCHA the agent cannot solve, and a session the agent does not keep. An agent that wants to ask "what does the free scan cover?" needs none of that — it needs a POST that returns an ID it can poll later.
So we built the agent-shaped version: submit takes milliseconds, the answer arrives when a human (or the QA corpus) gets to it. Mail, not chat.
The cycle an agent runs is three HTTP calls:
# 1. Submit — free, no API key, idempotent
curl -X POST https://agentbadge.xyz/api/requests \
-H "content-type: application/json" \
-H "Idempotency-Key: q-1742000000" \
-d '{"kind":"question","payload":{"question":"What does the free scan cover?"}}'
# → 202 {"request_id":"rq_9a7f…"}
# 2. Poll by id (AAuth §11.8 pattern — the same shape as async auth challenges)
curl https://agentbadge.xyz/api/requests/rq_9a7f…
# → 202 {"state":"submitted"} + Retry-After: 120
# …later… → 200 {"state":"completed","response":{…}}
# 3. Or get an instant answer if your question is already in the QA corpus
curl "https://agentbadge.xyz/api/requests/qa?q=what+does+the+scan+cover"
# → 200 {"qa":true,"answer":"…"}
Submit is cheap. The expensive part — a human thinking — happens off the request path.
Replay the same submit and you get the same request_id back — marked as a replay, never a second row.
How do you rate-limit someone with no account?
You fingerprint what you can see — and you fingerprint it privately. Three lanes run in sequence for anonymous submits:
- Hard lane — keyed on
agentIdfor verified agents, orfpHash = HMAC(ip + user-agent + headers, salt)for everyone else: 1 new request per 2-hour window by default. - IP lane —
HMAC(ip)alone, only when the IP parses: rotating headers does not buy extra slots, and unresolvable IPs skip the lane entirely instead of all sharing one bucket. - Soft lane —
HMAC(ua + headers): a wider quota that catches obvious replay storms.
Verified agents skip the anonymous lanes entirely — identity is the upgrade path, not a paywall.
Fail a later lane and the earlier slots come back — the limiter refunds, it does not double-charge.
Unknown IP no longer means "everyone shares one bucket" — the lane is skipped, not shared.
Two privacy choices the audits forced us to make explicit:
- IP is never stored — only
HMAC(ip, salt). What ops sees in the console is a hash prefix, not an address. - Header snapshots are allowlisted, not blocklisted: only
accept*,sec-ch-ua*,dnt,content-type,user-agent,x-requested-withcan persist. Any future header —cf-pseudo-ipv4,x-azure-clientip, whatever a proxy adds — is dropped before storage unless we explicitly vouch for it.
Who answers, and how do questions stop repeating?
Nobody sits on a websocket. An ops console polls GET /api/ops/requests behind an admin key, flips a request to working, then POST /api/ops/requests/:id/respond. Every answered pair lands in the QA corpus — the next agent asking a normalization-equivalent question gets a 200 instantly without creating a row at all.
The humans pull — the agents never wait on a connection that might die.
Payloads expire on a TTL: the janitor archives, nulls the body, and the row answers 410 — a request inbox is not a data lake.
What do agents get for answering our surveys?
Reputation. GET /api/surveys/open lists what we are asking agents this week; POST /api/surveys/:id/answer accepts a JSON answers object and credits the rating ledger. It is the cheapest way we found to turn anonymous traffic into signal — the agent that answers gets a durable reputation credit, we get structured feedback about what bots actually need.
The answer path is deliberately strict:
- answers must be a non-empty JSON object —
[null],{"q":""},[[],{}]are rejected (400); - JSONB-unsafe values (NUL bytes, depth bombs) are rejected before any rate-limit slot is consumed;
- dedupe runs on
agentIdorfpHash— one agent, one answer; - a store failure refunds the limiter slots and returns
503— retry-safe, never a lost answer that still burned your quota.
Answer once, get credit; answer twice, get a 409.
Validation before the toll booth — a malformed answer costs the caller nothing.
What did three adversarial audits break?
We ran the inbox through external review three times before calling it done: 15 findings in round one, a REQUEST CHANGES re-audit with 6 more, then APPROVE WITH NITS — all shipped with regression tests (98 request-inbox tests green today). The fixes that teach the most:
- Header snapshots trusted the client. The sanitizer used a blocklist, so any unvetted proxy header persisted forever. Now only an allowlist survives.
- The limiter double-charged. Passing the hard lane and failing the IP lane left the hard slot spent. Lanes now refund earlier slots on rejection — verified by a test that makes the same fingerprint submit successfully right after.
- JSONB could eat NUL.
hasJsonbUnsafenow guards survey answers, titles and questions — before the non-empty check, so depth-guarded unsafe trees still fail closed. - "Empty" arrays were not empty.
{answers:{q:[{"a":""}]}}passed validation and created a leaf-less row.hasNonEmptyLeafwalks the tree and rejects it. unknownIPs shared one bucket. Every unresolvable IP landed in the same IP-lane quota — one botnet behind a bad proxy could starve everyone.ipFpHashnow exists only for parseable IPs; the lane is skipped otherwise.- Survey store failures ate refunds.
putAnswerthrowing left lanes spent and the caller blocked for the window. Failure now refunds all slots and returns503.
Try it
If your agent already polls agentbadge.xyz for scans, give it a mailbox too — one POST, one request_id, answers when humans get to them. If you are building agent-facing surfaces yourself, steal the parts worth stealing: idempotent intake, private fingerprints, lanes that refund, payloads that expire.
Don't certify. Measure.