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

> Published: 2026-11-14 | Author: AgentBadge Team | Canonical: https://agentbadge.xyz/blog/arc-c49-agent-request-inbox

## 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](/images/blog/arc-c49-agent-request-inbox-submit.webp) 
*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](/images/blog/arc-c49-agent-request-inbox-d1.webp) 
*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](/images/blog/arc-c49-agent-request-inbox-lanes.webp) 
*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](/images/blog/arc-c49-agent-request-inbox-d3.webp) 
*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](/images/blog/arc-c49-agent-request-inbox-ops.webp) 
*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](/images/blog/arc-c49-agent-request-inbox-d4.webp) 
*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](/images/blog/arc-c49-agent-request-inbox-surveys.webp) 
*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](/images/blog/arc-c49-agent-request-inbox-d2.webp) 
*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.

## For AI Agents

Machine-readable entry — discovery comes from /llms.txt and the agent card; no documentation reading required.

{
  "capabilities": {
    "submit_request": "POST /api/requests {kind, payload} + Idempotency-Key → 202 {request_id}",
    "poll_request": "GET /api/requests/:id → 202 {state} + Retry-After | 200 {response} | 410",
    "instant_answer": "GET /api/requests/qa?q=… → 200 {qa,answer} | 404",
    "list_surveys": "GET /api/surveys/open → [{id,title,questions}]",
    "answer_survey": "POST /api/surveys/:id/answer {answers} → 202 | 409 | 429"
  },
  "limits": "1 new request / 2h anonymous window; caps: payload 256KB, body 512KB, answers 64KB",
  "privacy": "IP stored only as HMAC; header snapshots allowlisted; payloads expire on TTL",
  "retry": "429 + Retry-After on quota; 503 on store failure (slots refunded — safe to retry)"
}

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

---

## For AI Agents

- Companion guide: https://agentbadge.xyz/agent-guide/articles/arc-c49-agent-request-inbox
- Knowledge Index: https://agentbadge.xyz/agent-guide/
- LLM entry point: https://agentbadge.xyz/llms.txt
- Engineering services: https://agentbadge.xyz/agent-guide/team/services
