# Bots Visit Your Site Every Day. We Gave Them a Mailbox — the Agent Request Inbox

> Source: <https://agentbadge.xyz/blog/arc-c49-agent-request-inbox>
> Published: 2026-11-14 00:00:00+00:00

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

# 2. Poll by id (AAuth §11.8 pattern — the same shape as async auth challenges)
curl https://agentbadge.xyz/api/requests/req_8f3c…
# → 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`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.

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

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

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