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:
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?"}}'
curl https://agentbadge.xyz/api/requests/req_8f3c…
curl "https://agentbadge.xyz/api/requests/qa?q=what+does+the+scan+cover"
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.