This guide is for the developer who connects your servers to agent traffic during the pilot. It covers getting a key, sending your first events, seeing them on your dashboard, what each field drives, the full event reference, how long agent traffic keeps your data, and what is not built yet.
Every request goes to https://alg.katrinalaszlo.com. (https://agent-traffic.vercel.app serves the same deployment.)
Part of agent-led-growth. Test it first: npx agent-led-growth check <url> runs a real agent through signup, key and first call on your product, before you ship; agent traffic measures which agents become customers after.
Install with a coding agent
If your API runs on Node with Express, a coding agent (Claude Code, Cursor, Codex) can do the whole install. Give it this prompt:
Paste this into Cursor or Claude Code, not your terminal:
Add agent traffic to this API: read https://alg.katrinalaszlo.com/SKILL.md and follow it.The skill is three steps of plain shell and one code edit, with nothing downloaded and run: a signup curl that appends the key to .env without printing it (POST /v1/signup?format=env), the @katrinalaszlo/agent-traffic npm package plus two lines in your app, where the agent wires the one choice that matters (which field identifies your customer, see "Where sessions come from" in section 5), and a curl to POST /v1/install-check, which fails unless sessions, status codes and distinct event names arrive.
For agents and scripts, this guide is also served as markdown at https://alg.katrinalaszlo.com/docs.md, and /docs answers Accept: text/markdown with the same file. The rest of this guide is the manual path and the full reference.
1. Get your key and project
One call creates everything you need: a workspace, an API key for it, and a traffic project in it.
curl -sS -X POST https://alg.katrinalaszlo.com/v1/signup \
-H "Content-Type: application/json" \
-d '{ "label": "traffic pilot", "scopes": ["read", "write"] }'The answer is 201 with:
{
"workspace": "ws550ebb4f031daf0b495b664b",
"key": "ark_ws550ebb4f031daf0b495b664b_<64 hex characters>",
"project_id": "485f7b24-5fa5-40fc-ad0b-136550b78db4",
"claimed": false,
"scopes": ["read", "write"],
"message": "Send this key as Authorization: Bearer. It is shown once and cannot be recovered. Send events to POST /v1/events?project_id=<project_id>.",
"claimCode": "clm_ws550ebb4f031daf0b495b664b_<64 hex characters>"
}keyis shown once and only its hash is stored, so save it in your secret store right away. Send it asAuthorization: Bearer <key>. Thews…part betweenark_and the next underscore is your workspace id.project_idnames the traffic project signup created for you, named after yourlabel(ordefaultwithout one). Every event goes to this project.scopes: sending events needswrite, and reading your data needsread, so ask for both. If you leavescopesout, the key gets all three (read,writeandrun; nothing in this guide usesrun). A key cannot be widened later, so a key created with onlyreadwill never be able to send events.claimCodeis not needed for anything in this guide.
You do not need to ask us to create a project. If you need a second project in the same workspace, ask us.
2. Send your first events
Events go to POST /v1/events, with your project_id in the query string and a JSON array of events as the body. This call sends two events of one session: an agent reads your docs, then gets a 401 from your API.
AGENT_TRAFFIC_KEY="ark_ws..." # from signup
PROJECT_ID="..." # from signup
DOCS_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
sleep 1
CALL_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
curl -sS -X POST "https://alg.katrinalaszlo.com/v1/events?project_id=$PROJECT_ID" \
-H "Authorization: Bearer $AGENT_TRAFFIC_KEY" \
-H "Content-Type: application/json" \
-d "[
{ \"idempotency_key\": \"first-1\", \"name\": \"docs_view\", \"occurred_at\": \"$DOCS_AT\", \"source\": \"sdk\",
\"session_hint\": \"first-run\", \"user_agent\": \"Claude-User (claude-code/2.1)\" },
{ \"idempotency_key\": \"first-2\", \"name\": \"api_call\", \"occurred_at\": \"$CALL_AT\", \"source\": \"sdk\",
\"session_hint\": \"first-run\", \"user_agent\": \"Claude-User (claude-code/2.1)\",
\"properties\": { \"status_code\": 401, \"error\": \"Unauthorized: invalid API key\" } }
]"The answer is 202 with:
{
"accepted": 2,
"rejected": []
}Always check accepted and rejected, not only the status: a 202 means agent traffic read the batch, not that it wrote every event (section 6).
Two mistakes look like success or like a missing project:
- Wrapping the array. A body of
{ "events": [ ... ] }is read as one event with an unknown field. The answer is202with"accepted": 0and a rejected entry with reasonunknown_field. Send the array itself. - Leaving out
?project_id=. The answer is404 not_found, the same as for a project that is not yours.
A single event without a session_hint or account_id is accepted and counted in your totals, but it is unattributed: it belongs to no session, so it appears in no path, in no Fix next entry and in no agent's row. In production, send a hashed account_id on every request that has a customer behind it (section 5, "Where sessions come from").
3. Viewing your dashboard
Open https://alg.katrinalaszlo.com/login, paste your API key and submit (the form's one field is named key). The key must have the read scope. Agent traffic checks it and keeps it in a cookie on that browser for 30 days, then sends you to your dashboard. The cookie is HttpOnly (page scripts cannot read it; the only page script is the install page's live check, which calls the API with the cookie but cannot read it), Secure and SameSite=Lax. The key never appears in a URL. The dashboard has pages for Overview, Agents, Conversions, Paths, Install and Settings. "Sign out" in the top bar clears the cookie; it is a POST, so a link prefetch cannot sign you out.
The dashboard shows, for the last 14 days:
- Total events, failed sessions and unattributed events at the top.
- Fix next: the errors that ended sessions, most sessions lost first.
- Agents and Intents: which agents sent sessions, what class they were put in, and what they did first after your docs, with success and failure rates.
- Conversions: signups, keys, activations and payments your server reported with
recordOutcome(section 5), with revenue per currency, by the class of agent whose session led to them. - Where each agent fails: failure rate per agent and intent. Each cell lists its sessions.
- Agent paths and failed sessions, event by event. Each session opens a page with every event and its properties as you sent them.
- Identity tiers, sources and the daily breakdown, from the daily coverage snapshots.
Day one. When you open the dashboard and events have arrived since the last refresh, agent traffic builds sessions before showing the page, at most once every 10 minutes. So the sessions from section 2 show up as soon as you load the page, not the next day. If that refresh takes more than 20 seconds, the page shows what it has, with a banner giving the time of the data.
Daily coverage is different: it counts complete UTC days only, and the snapshot job runs at 02:00 UTC. On your first day the top total is labeled "so far, not yet in a daily snapshot", and the coverage card says when the first snapshot will be ready, for example "First snapshot available after 02:00 UTC on 2026-10-21". The two agree from then on.
To refresh from your own code instead of by opening the page:
curl -sS -X POST https://alg.katrinalaszlo.com/api/refresh \
-H "Authorization: Bearer $AGENT_TRAFFIC_KEY" \
-H "Content-Type: application/json" \
-d "{ \"project_id\": \"$PROJECT_ID\" }"The answer is 200 with { "success": true }. It needs a key with read, and answers 404 for a project outside your workspace.
Rotating a key
If a key leaks, or on a schedule, rotate it. The key in Authorization mints its successor with the same scopes; the old key keeps working for a grace window (24 hours unless you say otherwise, up to 168) so you can switch every service that holds it and restart them, then it answers 401 key_rotated. Any key can rotate itself, whatever its scopes. A key that has been rotated cannot rotate again; its successor can.
curl -sS -X POST https://alg.katrinalaszlo.com/v1/keys/rotate \
-H "Authorization: Bearer $AGENT_TRAFFIC_KEY" \
-H "Content-Type: application/json" \
-d '{ "grace_hours": 24 }'The answer is 201 with key (shown once), scopes, grace_until and a message saying what to do. ?format=env answers one AGENT_TRAFFIC_KEY= line instead, to replace the one in your .env. The middleware reads the key when the process starts, so replacing the line is not enough: restart or redeploy every service before grace_until. While the old key is inside its grace window, every 202 it earns on POST /v1/events carries a warnings array saying when it stops working; the middleware logs that once per process.
Checking sessions through the API
The dashboard's session sections come from GET /v1/traffic/paths, which works on day one, unlike coverage. Refresh first, then read the last day:
curl -sS "https://alg.katrinalaszlo.com/v1/traffic/paths?project_id=$PROJECT_ID&days=1" \
-H "Authorization: Bearer $AGENT_TRAFFIC_KEY"The answer has events, unattributed_events, sessions, terminal_counts (how many sessions ended in success, failure or unknown), terminal_rules (how many endings came from a recorded status_code or a name_heuristic), the top paths with their steps, and the agents, intents, fix_next (each error with failed_at, the event most of its sessions ended on, and failed_at_steps, every event it ended sessions on and how many) and failure_timelines the dashboard shows. days follows the same rule as coverage (1 to 90). An install passes when events and sessions are present, at least one session ends with a recorded status code, and the steps show more than one event name. A failure guessed from its name does not prove status instrumentation works. POST /v1/install-check runs these checks for every project in your workspace, after refreshing, and answers plain text with a PASS, FAIL or WARN line per check:
curl -sS -X POST https://alg.katrinalaszlo.com/v1/install-check \
-H "Authorization: Bearer $AGENT_TRAFFIC_KEY"3b. Ask your coding agent
The same reports are readable over the Model Context Protocol, so Claude Code, Cursor or Codex can answer "which agents paid this week" or "why did Cursor's sessions fail" from your terminal. The server is POST https://alg.katrinalaszlo.com/mcp: streamable HTTP, stateless (no session to open), read-only, and it takes the same key as the API in Authorization: Bearer. The key needs the read scope; a key without it gets a JSON-RPC error.
Claude Code:
claude mcp add --transport http agent-traffic https://alg.katrinalaszlo.com/mcp --header "Authorization: Bearer $AGENT_TRAFFIC_KEY"Cursor (.cursor/mcp.json) and Codex (~/.codex/config.toml uses the same fields under [mcp_servers.agent-traffic]):
{ "mcpServers": { "agent-traffic": { "url": "https://alg.katrinalaszlo.com/mcp", "headers": { "Authorization": "Bearer <your AGENT_TRAFFIC_KEY>" } } } }| Tool | Arguments | Answers |
|---|---|---|
list_projects | none | your projects: project_id, name, created_at, verified_tier_enabled |
traffic_coverage | project_id, days (1 to 90, default 14) | the GET /v1/traffic/coverage body: events per complete UTC day by tier, class and source |
traffic_paths | project_id, days | the GET /v1/traffic/paths body: paths, agents with class and failure rates, intents, fix_next, timelines |
conversions | project_id, days, kind (signup, key_created, activation, paid, custom) | outcomes by agent class with paid revenue per currency; kind adds that kind's count |
install_check | project_id | refreshes the project, then ok and the PASS/FAIL/WARN lines of POST /v1/install-check for it |
A refused call (unknown project, bad days) comes back as a tool result with isError: true and the API's status, code and message, so the agent can correct its arguments. The server offers no write tool: nothing an agent does here changes your workspace.
4. What data goes where
| Field | What it drives | Without it |
|---|---|---|
session_hint | Sessions. Every event with the same hint is one session, with no idle timeout. Sessions feed Agent paths, Fix next, Agents, Intents, the heatmap and the session pages. | Grouped by account_id instead, if present. |
account_id | Sessions when there is no hint: one account's events, split after 30 minutes without activity. | With neither field, the event is unattributed: counted in totals, in no session. |
properties.status_code | How a session ended, from its last event: below 400 is OK, 400 and above is a failure. Failures drive Fix next, failure rates and the red cells. | Guessed from the event name if it contains error, fail or unauthorized, or ends in _4xx/_5xx (for example api_call_401). Guesses are marked "(guessed)". Otherwise "No status", never counted as OK. |
properties.error | The error text in Fix next, timelines and session pages, after the status code: 401 Unauthorized: invalid API key. | The standard text for the status code: 401 Unauthorized. |
name | The steps of a path. A session's intent is its first event whose name is not docs or docs_…. The heatmap groups intents by keywords in the name (signup, api_key, billing, metering, checkout, other). | Required. |
signature_agent | The identity tier (declared versus anonymous) and the agent's name: the host in it, such as claude.ai. | The agent is named from user_agent. |
user_agent | The agent's name when there is no signature_agent: its first word, such as python-httpx. | The agent shows as Unnamed. |
occurred_at | The order of events in a session, and the UTC day coverage counts the event on. | Required. |
source | The events-by-source chart. | Required. |
Agent names are what the request said about itself. Nothing checks them.
5. Sending events in production
POST https://alg.katrinalaszlo.com/v1/events?project_id=<uuid>
Authorization: Bearer <your key>
Content-Type: application/jsonThe body is one event object, or an array of 1 to 100 events. The whole body must be 32 KB or less, so a full batch of 100 leaves about 327 bytes per event. If your events carry long user agents or large properties, send smaller batches.
Agent traffic checks each event on its own. A bad event is rejected and the rest of the batch is still written, and the response tells you which events were rejected and why (section 6).
Event fields
These eighteen fields are the only ones agent traffic accepts. Any other field, including fields agent traffic sets itself such as event_id, received_at or identity_tier, gets the event rejected with unknown_field. A required field sent as null counts as missing. The last five carry a Web Bot Auth signature for agent traffic to check; the @katrinalaszlo/agent-traffic package from 0.3.0 sends them when the request was signed, and an older package or an older agent traffic host leaves them out without harm.
| Field | Required | Rule |
|---|---|---|
idempotency_key | yes | String, 1 to 200 characters. Unique per event within the project (see below). |
name | yes | Lowercase letter first, then lowercase letters, digits or _, 64 characters at most: ^[a-z][a-z0-9_]{0,63}$. For example page_view or api_call. |
occurred_at | yes | ISO 8601 date and time with a timezone, Z or an offset such as +02:00. Seconds and fractions are optional. The date must exist on the calendar, so 2026-02-30 is rejected. |
source | yes | One of sdk, log, webhook, manual. |
account_id | no | Your customer's account id, hashed. See below. |
properties | no | A JSON object (not an array). Defaults to {}. |
session_hint | no | String. One value per task. See below. |
run_hint | no | String. |
user_agent | no | String. The User-Agent header as your server received it. |
accept_language | no | String. The Accept-Language header. |
signature_agent | no | String. The Signature-Agent header, as received. This is the field that decides declared versus anonymous. |
tls_fingerprint | no | String. |
signature | no | String. The Signature header, as received. |
signature_input | no | String. The Signature-Input header, as received. |
method | no | String. The request method in uppercase, such as GET. Rejected with invalid_method otherwise. |
url | no | String. The absolute http or https URL the agent requested, up to 2048 characters. A relative path is rejected with invalid_url. A signature needs this to be checked; without it the request is tiered rejected. |
outcome | no | Object { "name", "value", "currency" }: this event is a conversion. See "Outcomes" below. Needs account_id. |
covered_headers | no | Object of lowercase header name to string value, up to 32 of them: only the headers the signature covers, besides the three above. authorization, proxy-authorization, cookie and set-cookie are rejected with invalid_covered_headers, as is any value with a newline. Nothing in this object is stored; it is read once by the check. |
Every string, and every key and string value inside properties at any depth, must be text Postgres can store. That means no U+0000 character and no unpaired UTF-16 surrogate. In practice this only bites when you copy raw bytes from a log into a field.
An accepted event cannot be changed or deleted through the API, so if you send a wrong event, the fix is to send correct ones from then on.
Outcomes
An outcome is a conversion your server knows about and agent traffic cannot see in traffic: a signup, a key created, an activation, a payment. Send it as an event with an outcome object, and agent traffic writes an outcome row with the event, in the same transaction, so a retried batch with the same idempotency_key counts it once. The dashboard credits each outcome to the agent session that led to it and shows conversions and revenue by that session's class of agent (section 3). The rule: the most recent session of the same account_id whose class is known and that started within the 7 days before the outcome; if there is none, the session of the event the outcome arrived on. A payment from a billing webhook therefore lands on the agent session that signed the account up, as long as that session started within the week before it. An outcome with no such session, and whose own event has expired (section 8) or was in no session, is counted as unattributed. Revenue counts paid outcomes only; a value on another outcome name is stored and counted by name but is not revenue.
| Key | Rule |
|---|---|
name | One of signup, key_created, activation, paid, custom. Anything else is rejected with invalid_outcome. |
value | Whole minor units, 0 or more: 1999 for $19.99. "19.99" and 19.99 are rejected with invalid_outcome_value. Required for paid. |
currency | ISO 4217 code such as USD. Required whenever value is set; otherwise invalid_outcome_currency. Revenue is reported per currency and never summed across them. |
An outcome event without account_id is rejected with outcome_requires_account_id: the account is what joins it to the agent's session. Use the same hashed id you send on that customer's requests (below). With the @katrinalaszlo/agent-traffic package, traffic.recordOutcome({ accountId, name, value, currency, idempotencyKey }) builds and sends the event, hashing accountId with the same salt as the middleware; pass your payment event id or the new account's id as idempotencyKey.
account_id
Agent traffic never needs to know who your customers are. By default your workspace is in hashed mode, and in hashed mode account_id must be the HMAC-SHA256 of your account id, keyed with a salt that only you hold, written as 64 lowercase hex characters. Base64, uppercase hex or a plain account id gets the event rejected with invalid_account_id.
Agent traffic never sees the salt and never hashes anything for you. It checks that the value looks like a 64-character lowercase hex string and stores it as sent. That has two consequences you should plan for. First, agent traffic cannot tell when a value was hashed with the wrong salt, so a salt mix-up between two of your services shows up later as accounts that never join, not as an error now. Second, the salt has to stay the same for as long as you want hashed ids to line up across events, so store it like any other long-lived secret and use the same one everywhere you send events.
Use the salt as the HMAC key and your account id as the message, as in the examples below. Every service that sends events must do it the same way round; the other order also produces 64 hex characters, so nothing will warn you, the ids just won't match. Make the salt long and random (32 random bytes is plenty). A short or guessable salt can be found by anyone who has one of your account ids and its hash.
Leave account_id out, or send null, when there is no account behind the request.
A raw mode exists, where account_id is your plain id of 1 to 128 characters. A workspace can only be in raw mode with a recorded acknowledgment, and there is no setting you can change yourself, so ask us if you need raw mode. Events already stored stay as they were sent, so switching a workspace from hashed to raw part way through leaves older events hashed and newer ones raw, and they will not join with each other.
Where sessions come from
The requests agents send your API carry no task id, so on a server you usually cannot send a session_hint. Send account_id instead: the hashed id of the customer behind the request, from whatever your auth already resolved (user id, org id or API key id). Agent traffic splits one account's events into sessions after 30 minutes without activity. Requests with no customer behind them are counted but are in no session. Agent traffic does not group them by IP: agents share cloud IPs, so different agents would merge into one session.
Linking signup and rejected requests with accountIdOf
The requests where agents most often fail come before they are authenticated: the signup call itself, and calls with a key that is wrong or revoked. Agents will not carry a custom header across those steps, so your server links them, through accountIdOf. It runs after the response, so it can read what your handlers set while answering:
app.use(agentTraffic({
accountIdOf: (req, res) =>
req.user?.id // authenticated requests: what your auth resolved
?? res.locals.newAccountId // signup: set by your signup handler (below)
?? claimedAccountOf(req), // rejected key: the account the key claims to belong to
}));
// In your signup handler, once the account exists:
res.locals.newAccountId = account.id;
// If your keys name their account (sk_live_<account>_<secret>, for example), a rejected key still says which
// account the agent was trying to be. Adjust the pattern to your key format; return undefined if it has none.
function claimedAccountOf(req) {
const key = req.headers.authorization?.replace(/^Bearer /, "");
return key?.match(/^sk_live_([a-z0-9]+)_/)?.[1];
}All three must return the same id for the same account (the value req.user.id holds), or the signup and the rejected calls start sessions of their own. The signup then becomes the first step of that account's session, and a 401 from a mistyped or revoked key lands in Fix next under the account it claimed. A claimed account is only what the key says: nothing checked it, so treat those sessions as the agent's attempt, not proof of who sent it. Requests that name no account at all, such as reading public docs before signup, stay unattributed.
Send session_hint only if you have a task id of your own, for example a run id your product hands to the agent.
session_hint
A hint identifies one task: a signup flow, one attempt at an API integration, one end-to-end run. Give each task a new hint, and send it on every event of that task.
- Do not reuse a hint across tasks. Because a hint has no idle timeout, a hint that stays the same (a constant, a user id, one value for the life of an agent process) merges every task that used it into one long session.
- Do not send the hint on only some events of a task. Events with a hint and events without one are grouped separately, so the task is split into more than one session.
idempotency_key and retries
Give every event a key that you can regenerate for the same event, such as your request id. Agent traffic remembers each key for 30 days after it first received it. If you send the same key again within that window, agent traffic does not write a second event. It answers with a duplicate entry in rejected that carries the event_id of the event it already stored, and the duplicate does not count in accepted. If the second send has different content under the same key, the first event is the one that stays.
That makes retries safe. When a request times out, or answers 500 or 503, send the same batch again with the same keys. Events are written one at a time, so a batch that failed part way may have written its first events, and the retry reports those as duplicates and writes the rest. After 30 days a key is forgotten and the same key would be written as a new event.
occurred_at
occurred_at is when the request happened on your side, and coverage counts each event on the UTC day of its occurred_at, not the day agent traffic received it. Always include the timezone. The value is stored as the string you sent, so 2026-10-18T05:00:00Z and 2026-10-18T00:00:00-05:00 are the same instant but are not treated as the same content. Events of one session are ordered by occurred_at; events with the same occurred_at keep the order agent traffic received them in, so within one batch, array order.
An occurred_at more than 15 minutes ahead of the time agent traffic receives the event is rejected with future_occurred_at, so a server clock that runs ahead loses events until the clock is fixed. Keep your servers on NTP.
Example: curl with a hashed account_id
AGENT_TRAFFIC_KEY="ark_ws..." # from signup
PROJECT_ID="..." # from signup
SALT="..." # your own secret, never sent to agent traffic
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
ACCOUNT_HASH=$(printf '%s' "acct_123" | openssl dgst -sha256 -hmac "$SALT" | awk '{print $NF}')
curl -sS -X POST "https://alg.katrinalaszlo.com/v1/events?project_id=$PROJECT_ID" \
-H "Authorization: Bearer $AGENT_TRAFFIC_KEY" \
-H "Content-Type: application/json" \
-d "[{
\"idempotency_key\": \"req_7f3a9c\",
\"name\": \"api_call\",
\"occurred_at\": \"$NOW\",
\"source\": \"log\",
\"account_id\": \"$ACCOUNT_HASH\",
\"user_agent\": \"Claude-User (claude-code/2.1)\",
\"properties\": { \"status_code\": 200 }
}]"Example: Express middleware
The @katrinalaszlo/agent-traffic npm package is Express middleware in one file (Node 18 or later, no dependencies, TypeScript types included), open source at https://github.com/katrinalaszlo/agent-traffic-skill. It works with require and with import. It sends one event per request after the response is sent, in batches of up to 25 once a second, retries server errors with the same idempotency keys, logs rejections, and never blocks or fails a request.
npm install @katrinalaszlo/agent-trafficRegister it right after creating the app, before auth and routes, so requests your auth rejects are recorded too:
const { agentTraffic } = require("@katrinalaszlo/agent-traffic");
// Your customer's account id, as your auth resolved it. Read after the response, so values your auth middleware set
// are there. Return undefined when there is no customer behind the request.
app.use(agentTraffic({ accountIdOf: (req, res) => req.user?.id }));accountIdOf gets req and res, so auth that sets res.locals.user works too. The optional errorOf(req, res) returns error text for a failed response (status 400 and above), sent as properties.error up to 200 characters, so two errors with the same status, such as a quota 402 and a payment 402, show as separate rows in Fix next: errorOf: (req, res) => res.locals.error, with your handlers setting res.locals.error. It reads AGENT_TRAFFIC_KEY, AGENT_TRAFFIC_PROJECT_ID and AGENT_TRAFFIC_ACCOUNT_SALT from the environment, or from the AGENT_TRAFFIC_ lines of a .env in the working directory, the middleware's folder or any folder above it, when the environment lacks them. You can also pass key, projectId and salt as options.
Each event it sends:
| Field | Value |
|---|---|
outcome | Only on events recordOutcome sends: { name, value, currency }, with name as the event name and properties empty. |
name | The route pattern, so /v1/users/42 and /v1/users/43 are one step. A leading version segment and api are dropped, and the method is added for anything but GET: GET /v1/docs/quickstart is docs_quickstart, POST /api/v1/api-keys is api_keys_post. A request no route matched is unmatched. |
properties | { "status_code": res.statusCode, "method": req.method }, read after the response finished, plus error when you set errorOf and the response failed. |
account_id | HMAC-SHA256 of what accountIdOf returns, keyed with AGENT_TRAFFIC_ACCOUNT_SALT. Left out when it returns nothing. |
user_agent, signature_agent, accept_language | The request headers, as received. |
idempotency_key, occurred_at, source | A random UUID per event, the time the request arrived, and sdk. |
Event names exclude router mount prefixes because Express exposes their matched values, which can contain raw IDs. A route /orders/:orderId mounted at /customers/:customerId records orders_orderid for every customer. Identical route patterns under different mounts share an event name.
On serverless hosts (Vercel functions, AWS Lambda, Netlify, detected from their environment variables) the process can be frozen as soon as the response is sent, so there the middleware sends each event at once instead of batching, and on Vercel hands the send to waitUntil so the function stays up until it finishes. On Lambda and Netlify an event can still be lost if the process freezes mid-send.
For another framework, build the same event from the hook that runs after the response is sent, so you have the status code.
6. Reading the response
When agent traffic reads your batch, it answers 202 with how many events it wrote and one entry for each event it did not write:
{
"accepted": 2,
"rejected": [
{ "idempotency_key": "req_7f3a9d", "reason": "invalid_occurred_at" },
{ "idempotency_key": "req_7f3a9c", "reason": "duplicate", "event_id": "0b6f5c1e-3d1a-4e8e-9f0a-6a1d2c3b4e5f" }
]
}A 202 therefore does not mean every event was written, so always read rejected. Each entry names the first rule the event broke, and idempotency_key is null when the event had no string key to echo back.
| Reason | What happened | What to fix |
|---|---|---|
invalid_event | The event is not a JSON object. An empty body also lands here, as one rejected event. | Send an object, or an array of objects. |
unknown_field | The event has a field outside the twelve in section 5, or a field agent traffic sets itself. | Remove the field. Put your own data in properties. |
missing_field | idempotency_key, name, occurred_at or source is missing or null. | Send all four. |
invalid_idempotency_key | The key is not a string, is empty or longer than 200 characters, or holds text Postgres cannot store. | Use a string of 1 to 200 characters. |
invalid_name | name does not match ^[a-z][a-z0-9_]{0,63}$. | Use lowercase letters, digits and _, starting with a letter. |
invalid_occurred_at | Not an ISO 8601 date and time with a timezone, or a date or time that does not exist. | Send something like 2026-10-18T05:00:00Z. |
future_occurred_at | occurred_at is more than 15 minutes ahead of when agent traffic received the event. | Fix the clock on the server that set the time. |
invalid_source | source is not sdk, log, webhook or manual. | Use one of the four. |
invalid_account_id | In hashed mode, not 64 lowercase hex characters. In raw mode, not 1 to 128 characters of storable text. | In hashed mode (the default), send the lowercase hex HMAC-SHA256 digest. In raw mode, send 1 to 128 characters. Or leave the field out. |
invalid_properties | properties is not a JSON object, or a key or string inside it holds text Postgres cannot store. | Send an object. Remove null characters (U+0000) and lone surrogate characters from its keys and string values. |
invalid_session_hint, invalid_run_hint, invalid_user_agent, invalid_accept_language, invalid_signature_agent, invalid_tls_fingerprint | That field is not a string, or holds text Postgres cannot store. | Send a string, or leave the field out. |
duplicate | An event with this idempotency_key was already stored in this project within the last 30 days. Carries that event's event_id. | Nothing, if this was a retry. If it was a new event, give it a new key. |
When the whole request fails, nothing in the batch was looked at event by event, and the answer is an HTTP error with a body of { "error": "<code>", "message": "<text>" }.
| Status | Code | What it means |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 400 | invalid_field | The array is empty or has more than 100 events ("Send 1 to 100 events."). |
| 400 | request_aborted | The connection closed before the whole body arrived. Retry. |
| 401 | invalid_key | The key is malformed, unknown or revoked. Check the key, or create a new one. |
| 401 | key_rotated | The key was rotated and its grace window has passed. The body names rotated_at and expired_at. Set AGENT_TRAFFIC_KEY to the key the rotation returned and restart your server. |
| 409 | key_already_rotated | Only on POST /v1/keys/rotate: this key has already been rotated; rotate with its successor instead. |
| 401 | sign_in_required | No Authorization: Bearer header was sent, or the value does not look like an agent traffic key. |
| 403 | insufficient_scope | The key is valid but lacks the scope this route needs (write for events, read for coverage). The body names required_scope and granted_scopes. Create a key with that scope. |
| 404 | not_found | The project_id is missing, is not a UUID, does not exist, or belongs to another workspace. All four cases get the same answer on purpose, so check that the key and the project come from the same workspace. |
| 413 | request_too_large | The body is over 32 KB. Send smaller batches. |
| 503 | hosting_not_configured | The deployment has no database to write to. This is on our side. Retry later and tell us if it persists. |
| 500 | internal_error | Something failed on our side, possibly after some events in the batch were written. Retry the same batch with the same keys. |
The body and the batch size are checked before the project, so a request that is both malformed and aimed at the wrong project answers 400, not 404.
7. Reading coverage through the API
The dashboard reads the same report. To read it from your own code:
curl -sS "https://alg.katrinalaszlo.com/v1/traffic/coverage?project_id=$PROJECT_ID&days=14" \
-H "Authorization: Bearer $AGENT_TRAFFIC_KEY"days is how many complete UTC days to cover, ending yesterday. It defaults to 30 and must be a whole number from 1 to 90, otherwise the answer is 400 invalid_field. The project_id rules and the 404 are the same as for sending events. The report is read fresh on every request, so it changes as soon as the state behind it does.
The answer always has project_id and range_days, and then either pending or days.
pending
pending means no snapshot exists yet for this project, and it comes in two forms. Before agent traffic has received any event for the project:
{
"project_id": "…",
"range_days": 30,
"pending": {
"state": "pending",
"message": "Waiting for the first events. Snapshots start the day after the first event, after 02:00 UTC.",
"first_received_date": null,
"available_after": null
}
}After events have arrived but before the first snapshot run that can count them, the message and available_after tell you when to look again:
{
"pending": {
"state": "pending",
"message": "First snapshot available after 02:00 UTC on 2026-10-21",
"first_received_date": "2026-10-20",
"available_after": "2026-10-21T02:00:00Z"
}
}The snapshot job runs once a day at 02:00 UTC. The first snapshot comes from the first run that falls after both of two moments, when agent traffic received your first event and the end of the UTC day of your earliest occurred_at. If your first event arrives at 15:00 UTC on October 20 and happened that day, the first snapshot is available after 02:00 UTC on October 21. If you backfill old events on October 20, the answer is the same, because a run has to happen after the events arrived.
days
Once snapshots exist, days lists one entry per UTC date, newest first, from the earliest snapshot (or the start of the range, whichever is later) through yesterday. Each entry is either a snapshot or missing.
{
"project_id": "…",
"range_days": 30,
"days": [
{
"date": "2026-10-22",
"state": "snapshot",
"total_requests": 412,
"by_identity_tier": { "verified": 0, "declared": 37, "anonymous": 375, "rejected": 0, "not_assessed": 0 },
"by_bucket": { "unknown": 412 },
"unclassified_share": 1,
"js_instrumented_share": null,
"sdk_source_breakdown": { "sdk": 412, "log": 0, "webhook": 0, "manual": 0 },
"method_version": "coverage-1",
"notes": [
"0 verified: Web Bot Auth signatures are not checked yet",
"0 classified: no session that day matched an agent rule or carried a signature"
]
},
{
"date": "2026-10-21",
"state": "missing",
"notes": ["Snapshot missing: the coverage job may not have run for 2026-10-21"]
}
]
}The numbers above are an example. In a snapshot, total_requests is every event agent traffic stored with an occurred_at on that UTC date, the five tiers and the four sources each add up to that total, and js_instrumented_share is always null because no JavaScript SDK exists yet. A day with no events is still a snapshot, with total_requests of 0, unclassified_share of null, and the note "No events".
A missing day means the job did not write that day, and it carries no counts. Each night the job recomputes the last 7 complete UTC days, so a gap of a day or two usually fills in on the next run. A day that stays missing for longer than that will not fill in, and you should tell us.
That nightly recompute also means a snapshot can change for up to 13 days after its date (raw retention minus one day), as late events arrive. An event whose occurred_at is more than 13 days before agent traffic receives it is stored but never added to a snapshot, because its day has already left the window. Days before your project was created are never counted.
The method-change marker
Every snapshot records the method_version that produced it, coverage-1 today. When agent traffic changes what a count means, it writes snapshots under a new version beside the old ones and never rewrites the old ones. The report shows the highest version for each date, and the first date counted under a new version carries the note Counts from this date use method coverage-2 (with the new version's name), so you know not to compare across that line without thinking.
Notes
Every entry has a notes array of short text meant to be shown next to that day. These are all the notes the report can return:
| Note | When |
|---|---|
Snapshot missing: the coverage job may not have run for <date> | The day has no snapshot. |
Counts from this date use method <version> | The method version changed from the previous snapshot date. |
No events | The snapshot has total_requests of 0. |
0 verified: Web Bot Auth signatures are not checked yet | verified is 0 and signature checking is off for the project (every project created before 2026-10-22, until we switch it on with you). |
0 verified: no request carried a Web Bot Auth signature that checked out | verified is 0 and signature checking is on: no event that day carried a signature, or every one that did was rejected. |
<n> events were not assessed for identity tier | not_assessed is above 0. |
0 classified: no session that day matched an agent rule or carried a signature | unclassified_share is 1: every event that day is in the unknown bucket. |
Identity tiers
The identity tier says how much the request told you about who sent it. Every snapshot carries all five tiers, including the ones at zero, so the shape of the answer never changes.
| Tier | What it means today |
|---|---|
verified | The request carried a Web Bot Auth signature that agent traffic checked against the key the agent published: the signature covered the request, the key came from the issuer named in Signature-Agent, and the signature was fresh. It says who signed. It does not say the agent behaved well, and it is 0 for a project whose signature checking is off. |
declared | The event's signature_agent field held a Signature-Agent value whose every entry is an http or https URL. The agent named itself, but nobody checked the claim. |
anonymous | The event had no signature_agent, or had one whose entries were not all web URLs. |
rejected | A signature was present and failed the check: expired, wrong key, a key set that could not be fetched, a covered part of the request that was not sent, or an issuer agent traffic refuses to fetch from (an IP address, a local name, or a name that resolves to a private address). The reason is stored with the event. |
not_assessed | Agent traffic could not assign a tier to the event because tiering failed on our side. The event is still stored and counted. |
A Web Bot Auth signature covers the HTTP request as your server received it, so the check needs that request: its method, its full URL, the Signature and Signature-Input headers, and whatever other headers the signature covers. The @katrinalaszlo/agent-traffic package sends those with the event when the request was signed (section 5, the last five fields), and agent traffic checks them on receipt, fetching the agent's published key set once per issuer and caching it. Signature checking is a per-project switch. Projects created from 2026-10-22 have it on; older projects have it off until we switch it on with you, and their snapshots say not checked yet rather than counting unsigned requests as a finding. When it is on and a day's verified is 0, the note says so in those words: no request that day carried a signature that checked out.
Classification puts each session in one of five buckets from what its events say about the sender, and by_bucket counts events by their session's bucket: known_agent (an event with a checked signature, or a user agent on the shipped list of agent vendors), declared_agent_assisted (the agent named itself in Signature-Agent, nobody checked, and no vendor user agent matched; a vendor user agent with an unchecked signature is still a known_agent), suspected_automation (an HTTP client library or a headless browser), human (a browser), and unknown (nothing matched, or the event is in no session). unclassified_share is the unknown share of the day. The rules are case-insensitive patterns over the User-Agent header, versioned, and a session's row names the ones that matched. Buckets say how the sender looked, not what it did: a browser user agent is a string any program can send.
8. Data retention
Agent traffic keeps your raw events for your workspace's raw retention plus one extra day, and then the daily job deletes them. Retention is 14 days by default, which means events from the last 15 full UTC days plus today are kept. The extra day is there so that every day the snapshot job recounts still has all of its events.
The cutoff follows occurred_at, not when the event arrived. If you backfill events whose occurred_at is already older than the cutoff, they are deleted by the next daily run, within a day of arrival. They would not have appeared in a snapshot anyway, because they are outside the 7-day recount window.
Events that arrive more than 13 days after their occurred_at are stored (until the retention cutoff deletes them) but not added to any coverage snapshot, because the snapshot job recomputes only the complete days inside raw retention (13 with the default of 14). A longer retention widens that up to 14 days. If you plan to backfill historical data, contact us before sending it. We can tell you which events will appear in your report and whether a one-off recount is possible for the rest.
Idempotency keys are kept for 30 days from when agent traffic received them, separately from the events, so a retry within 30 days is still recognized as a duplicate even after the event itself has been deleted.
Daily snapshots hold counts, not events, and the daily job does not delete them. Your coverage history stays readable after the raw events behind it are gone.
Two things to plan for:
- Volume. If you expect to send more than a few thousand events a day, tell us before you start.
- Retention. Plan on 14 days. There is no setting you can change yourself yet; ask us if you need longer.
9. Limits and what is not built yet
There is no sign-out button. To sign out of the dashboard, clear the site's cookies.
Signup creates one project. For a second project in the same workspace, ask us. The same goes for workspace settings such as raw retention and the account id mode, which you cannot change yourself yet.
Sessions are built from every stored event still inside raw retention when the refresh runs (14 days by default, plus one slack day). Events sent after their raw retention has passed are deleted on the next expiry run and never placed in sessions.
Signature checking is off for projects created before 2026-10-22, so their verified and rejected tiers stay 0 and declared is the highest tier they show. Ask us to switch it on; there is no setting you can change yourself yet.
Classification uses the shipped rule set only; there is no way to add your own rules yet. Ask us if an agent that matters to you lands in unknown.
There is no API to read raw events back. The dashboard's session pages show each session's events and their properties. Events are written once and cannot be changed or deleted through the API.
The Express middleware in section 5 is the @katrinalaszlo/agent-traffic npm package. For other frameworks you send events from your own code with the calls above.