We've shipped 244 paid endpoints across the agentutility portfolio. The first 30 taught us almost nothing. The next 200 taught us a lot. Here's what survived.

snake_case wins, and it's not close

Pick agent_id, not agentId. Pick tx_hash, not txHash or transactionHash. Two reasons.

First, agents read schemas through tokenizers. tx_hash tokenizes cleanly into tokens an LLM has seen a million times. txHash splits weirdly depending on the model. Over a long tool-call chain that drift adds up.

Second, snake_case matches what most API docs in an LLM's training set already use. The model knows start_date is a date. It has to guess about startDate. Tiny edge, but consistent.

The exception is upstream-mirroring endpoints. If you're wrapping Stripe, keep Stripe's casing. Don't make the agent translate.

Required fields are a tax

Every required field is a place an agent can fail. We measured this on the secrets-exposure-check endpoint. When we made repo_url required and branch optional with a main default, success rate on first call went from 71% to 94%.

Default everything you can. If a field has an obvious answer 90% of the time, default to it and let the agent override. Agents are bad at remembering they need to pass currency: "usd" when 100% of your traffic is USD anyway.

But don't default fields where the wrong default silently changes meaning. max_results defaulting to 10 is fine. include_private defaulting to true is a footgun.

Enums beat free-form strings

status should be "ok" | "rate_limited" | "upstream_down" | "invalid_input". Not a string the agent has to parse with a regex.

We learned this the slow way. The satellite-tile endpoint originally returned error: "tile out of bounds for zoom 12" as a plain string. Half the agents calling it treated that as a successful response and tried to render the message as an image. After we switched to status: "invalid_input", error_code: "tile_out_of_bounds", the misroute rate dropped to near zero.

Keep enum values short and lowercase. ok, not Success or OK. Save the human-readable explanation for a separate message field.

Errors in-band, status codes for transport

This one's contentious. Our rule: HTTP status reflects the transport layer. The body always has structured error data, even on 200.

So a 200 with { "status": "rate_limited", "retry_after": 30 } is fine. A 402 means "you didn't pay." A 500 means "we broke." A 400 means "your request was malformed before we even tried."

Why? Because x402 middleware sits between your handler and the client. If you return 429 from your handler for "user hit their quota", some clients short-circuit and don't read the body. Putting the rate-limit signal in the body means every well-behaved agent sees it.

The exception is auth and payment. Use the real HTTP codes there. Standards exist for a reason.

Output shape: stable keys, optional values

Always return the same top-level keys. If a field doesn't apply, set it to null. Don't omit it.

{
  "status": "ok",
  "result": { "matches": 3, "max_severity": "high" },
  "details": null,
  "billed_amount": "0.005"
}

Agents written against your schema will check for details before reading it. If you omit the key on success and include it on failure, every agent has to write defensive code for both shapes. That's friction you can erase by returning null.

Version the schema

Stamp schema_version: "2" on every response. When we shipped a breaking change to the brand-clearance output last month, the bump from "1" to "2" let downstream agents pin their parser. Two lines of code. Saved a long support thread.