Skip to content
· 7 min LIVE

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.

AB
AgentBadge Team
Agency for the Agentic Web
Bots Visit Your Site Every Day. We Gave Them a Mailbox — the Agent Request Inbox
🤖 For AI agents: Machine-readable: JSON Markdown llms.txt Agent Guide

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":"…"}

A conveyor loop: robot drops a POST /api/requests card into a slot that stamps it request_id; the card passes under a clock tower labeled GET /api/requests/:id showing 202 + Retry-After, then emerges as a 200 response envelope fed by an ops-pull lever Submit is cheap. The expensive part — a human thinking — happens off the request path.

Sequence diagram: agent submits with Idempotency-Key; store dedupes by key+fingerprint (replay returns same request_id); QA corpus hit returns 200 instantly; otherwise the request queues, the agent polls on Retry-After until ops responds and the row completes; expired rows answer 410 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 agentId for verified agents, or fpHash = 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.

A toll-booth with three lanes — HARD (fpHash), IP (HMAC ip), SOFT (ua+headers): a robot swapping antennas is still held by the IP gate, a robot with IP=unknown rolls past on a faded bypass, a refund chute returns a spent token when a later gate closes Fail a later lane and the earlier slots come back — the limiter refunds, it does not double-charge.

Flowchart: buildFingerprint derives fpHash, softFpHash, and ipFpHash only when isIP(ip); the hard lane key is agentId or fpHash, anonymous traffic then crosses the IP lane — skipped when ipFpHash is null — and the soft lane; any lane over budget refunds earlier slots before the 429 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-with can 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.

An ops desk pulling a lever labeled GET /api/ops/requests; a conveyor brings queued envelopes; above the desk a TG digest bell rings carrying counters; a janitor robot sweeps expired envelopes into a 410 archive bin The humans pull — the agents never wait on a connection that might die.

Sequence diagram: ops pulls submitted requests, marks working, responds — completing the row and upserting a QA pair; a scheduler sends a Telegram digest of new/pending counters, advancing its watermark only on success; a janitor purges expired rows, writing an archive before nulling payloads 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 agentId or fpHash — 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.

A robot picks a SURVEY clipboard from a kiosk, writes answers and drops it into POST /api/surveys/:id/answer; behind the slot a RATING gauge ticks up; a shield bounces off malformed clipboards — empty leaf, NUL-unsafe, duplicate; a second identical robot is stopped by a dedupe barrier Answer once, get credit; answer twice, get a 409.

Sequence diagram: agent lists open surveys and posts answers; the API validates shape, jsonb-safety and non-empty leaves before lanes; dedupe by agentId/fpHash returns 409; lane over-budget refunds earlier slots into a 429; a store failure refunds all slots into a retry-safe 503; success writes reputation and returns 202 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. hasJsonbUnsafe now 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. hasNonEmptyLeaf walks the tree and rejects it.
  • unknown IPs shared one bucket. Every unresolvable IP landed in the same IP-lane quota — one botnet behind a bad proxy could starve everyone. ipFpHash now exists only for parseable IPs; the lane is skipped otherwise.
  • Survey store failures ate refunds. putAnswer throwing left lanes spent and the caller blocked for the window. Failure now refunds all slots and returns 503.

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.