Nobody complains: 28 silent failures of a paid API for AI agents
When your customer is a program, a broken checkout does not produce an angry email. It produces nothing. The agent gets an answer it cannot use, gives up on its side, and moves on to the next service in its list. From the outside, “nobody buys” and “nobody can buy” look exactly the same.
This is a field guide to the failures that wait for anyone building an API that machines pay for: x402 micropayments, agent catalogs, and delivery from Cloudflare Workers. Every one of them has no symptom. Pages load, logs stay clean, tests stay green. Each item has the same shape: the symptom, the cause, the fix, and the check that would have caught it. The numbers are measured, not guessed.
Part 1. Getting paid: the x402 layer
1. The 402 response is correct, and no standard client can pay it
Symptom: the service answers 402 Payment Required with a valid JSON body.
Your own payment script pays successfully. External buyers: none. Server logs: clean. That is the clue: a clean log
means the buyer never got as far as sending you anything.
Cause: in x402 version 2, the standard client reads the payment requirements
only from the PAYMENT-REQUIRED header. It accepts a body only when
x402Version === 1. Otherwise it throws Invalid payment required response before it signs
anything. This is in the source of @x402/core 2.24.0; the documentation does not say it plainly. Catalog
indexers validate the same way, so a storefront without the header is also rejected from discovery.
Fix: put the base64 of the exact same body into the header, on every v2 response. Three details, each of which fails silently:
- Standard base64, not URL-safe. The helper you use for JWTs is the wrong tool here.
- Encode UTF-8 bytes, not the string.
btoa()on text throws on the first non-Latin-1 character in a description or error message. - Send the header on refusals too, not only on the bare storefront. A buyer that receives a refusal without requirements has nothing to act on, and loops.
const body = JSON.stringify(requirements); // serialize ONCE
const bytes = new TextEncoder().encode(body); // UTF-8 bytes, not btoa(text)
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return new Response(body, {
status: 402,
headers: {
'content-type': 'application/json',
'PAYMENT-REQUIRED': btoa(bin), // standard base64, NOT url-safe
},
});
Serialize once and use the same string for both, or the two copies drift apart the first time someone reorders a field.
Check: decode your live header with decodePaymentRequiredHeader from
@x402/core/http. That is the buyer's own code, not a parser you wrote. If you still serve v1 clients,
check that path separately and make sure you did not change it.
2. “We got paid” proves your own script, not the protocol
Symptom: a real payment went through, and the service is still unpayable for every standard agent. This can last for weeks, because the one payment looks like proof.
Cause: a hand-written test buyer usually reads the body directly and assembles the signature by hand. It goes around exactly the part that is broken (item 1). A hand-written client proves that your server agrees with your client, and nothing else.
Fix: the first payment of any new service is made with the library that runs
inside other people's agents (@x402/fetch / @x402/core), with default settings.
Keep your own script for load and edge cases, never as proof that you can be paid.
3. The service says “selling”, the buyer signs, and gets an empty 500
Symptom: a buyer signs a transfer authorization and receives an empty 500.
No credit, no explanation.
Cause: “are we selling?” is inferred from state: a wallet is configured, a network is configured, so the storefront is open. The settlement credentials (the facilitator API key) are missing, and nothing checks them until the moment the service tries to settle. By then the buyer has already signed.
Fix: make selling an explicit decision, and check every precondition, including working settlement credentials, before the storefront opens. Any missing one closes the door. Two contradictory switches set together close it too; never pick a winner by priority. And when it is closed, say why in the 402 body and in your health endpoint, so the reason is visible from outside without anybody reading logs.
4. Your internal strings end up in a public catalog
Symptom: your entry in the public x402 Bazaar shows text you never meant to publish, such as a note in your own language or a placeholder example.
Cause: the catalog copies your payment requirements verbatim, including
the example output inside extensions.bazaar. Anything in your 402 body is also inside the base64 header,
and from there in every catalog that imports the Bazaar. A body is not internal just because humans never look at it.
Fix: everything that leaves the service is in one language, and every error has a stable
snake_case code next to a one-sentence message. The code is the contract; the message is for humans. Keep
a test that renders every public page, manifest and refusal and fails on anything you did not mean to publish, and
prove the test with a mutation (see item 28).
5. “Retry on 500” makes agents pay twice
Symptom: none, until it happens. Every paid API has one path where it holds someone's money without giving them anything: the facilitator settled the payment, and recording it on your side failed.
Cause: if that path returns 500, every HTTP client reads it as the canonical
“try again”. An agent that retries signs a new authorization and pays a second time with its own money.
Fix: a dedicated error code for this one case, an explicit "retryable": false
field placed before the message, a plain instruction not to pay again, and the payment nonce so the case can be settled
by hand. Every error an agent can receive should answer one question without prose: is it safe to do this
again?
Part 2. Getting found: catalogs and agent cards
6. extensions.bazaar: {} is valid syntax and an invisible listing
Symptom: everything green, and the service is not in the catalog.
Cause: an empty object passes as syntax and fails validation. The
Bazaar extension spec
requires info (with input.type and input.method, and for POST also
bodyType and body) plus schema, a JSON Schema the facilitator validates
info against before it catalogs anything.
Fix: a full info and schema. serviceName,
tags and the description stay on the resource.
7. Ask the validator, not the documentation
Coinbase's CDP exposes a validator that tells you the exact reason for a rejection. It is public, needs no key, and registers nothing:
curl -s -X POST https://api.cdp.coinbase.com/platform/v2/x402/validate \
-H "content-type: application/json" \
-d '{"resource":"https://your.site/buy/pack"}'
Three traps:
- The field is
resource, noturl. The wrong name returns a 400 that looks like a broken endpoint. - The first failed check hides the rest: they come back as
Skipped. Fix, rerun, repeat. - Run two controls in the same session: a resource that is already listed must come back
accepted, and a made-up URL must come backrejected. Without them, “rejected” cannot tell “you are broken” from “the validator rejects everything today”.
8. There is no sign-up form, and a listing expires quietly
Symptom: no way to “register”, then entries that were there and later are not.
Cause: a resource enters the CDP catalog when a payment to it is settled through
the CDP facilitator, usually within minutes. Payments settled through a different facilitator (for example
x402.org/facilitator) are not indexed by CDP at all, so a service on a testnet or behind another
facilitator does not exist for that catalog. A resource with no settlement for 30 days is dropped. The clock is
per resource: a sale of your cheap pack does not keep your expensive one listed.
Fix: watch the listing per row. A single “last sale” timestamp mathematically cannot alert on a stale row while another row keeps selling.
9. One listing shows machines your worst price
Symptom: you list one resource, the cheapest entry pack, and write the other prices into its description.
Cause: comparison bots read the structured amount and the number of units,
not your prose. One entry shows the highest price per unit you have, and never the lowest.
Fix: list the two Pareto extremes of the tariff: the lowest entry price and the lowest
unit price. Middle packs lose on both axes and add nothing for a machine, and four rows read like spam. Give both
entries identical serviceName and tags and cross-link the descriptions (“same service, bulk
pack at …”) so catalogs merge them into one service.
10. Your fix is live, and the catalog keeps showing the old entry
Symptom: you fix your OpenAPI document, x402scan's own validator shows far fewer warnings, and your page on x402scan still shows the resources from a registration weeks earlier.
Cause: the listing is not re-crawled on its own. There is a re-crawl button on the server's page, and after pressing it, routes can appear that were never listed before.
Fix: any change meant to be visible in a catalog ends with triggering the re-crawl and counting the rows. Until then the fix exists only on your side.
11. The indexer decides your auth by the name of the security scheme
Symptom: protected routes listed as failed.
Cause: x402scan indexes with @agentcash/discovery. Its security resolver
checks the scheme name before the type. The canonical OpenAPI 3 entry
bearerAuth: { type: 'http', scheme: 'bearer' } is not recognized, the auth mode stays undefined, and an
undefined mode is treated as “maybe paid, not yet discovered”. So the crawler calls the route without a token, gets
401 instead of 402, and marks it failed.
Fix: rename the scheme, keep the semantics:
"components": { "securitySchemes": {
"apiKey": { "type": "http", "scheme": "bearer" }
} }
Do not change the type to apiKey. Code generators read the type, and a client built
from type: apiKey sends the bare token, which a server expecting Bearer <token>
rejects. Renaming is reversible; breaking real clients to please one reader is not. Because this is undocumented
behaviour, pin the same package version the indexer pins and test against it, so you learn when it changes.
12. An A2A card that points to your website is a promise you are not keeping
Symptom: your A2A agent card lists skills, and the official client cannot connect at
all: No compatible transport found.
Cause: A2A 1.0 (spec 1.0.0, @a2a-js/sdk 1.2.0) reads the transport from
supportedInterfaces, not from url. And 1.0 renamed almost everything:
- Methods are
SendMessage,GetTaskand so on (0.3 usedmessage/send). - Parts are
{text}or{data, mediaType}withoutkind; roles areROLE_USERandROLE_AGENT; the result is{message: {…}}. - Clients send
A2A-Version: 1.0. A missing header means 0.3, and a 1.0 server refuses with-32009. - JSON-RPC errors must travel on HTTP 200. The Python client calls
raise_for_status()before reading the body, so on a 4xx your error code never reaches the agent. - The Python client parses the result strictly against the proto schema. One extra key breaks it on the client's
side, where you will never see it. Put your own data inside a data part, not next to
role.
"supportedInterfaces": [
{ "url": "https://your.site/a2a", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }
]
Fix: if you do not have an agent to put behind the card, build a receptionist: one
endpoint that answers every message with the same guide (prices, how to buy, where the docs are), built from
configuration, with no database, no model and no payment in the conversation. Serve the card at all three paths
readers knock on: /.well-known/agent-card.json, /.well-known/agent.json and
/agent.json. A 404 makes a reader walk away silently.
13. Charging per event loses money
Cause: on the CDP facilitator the first 1,000 settlements a month are free, then each costs $0.001. If your price per event is $0.001, the fee is 100% of the revenue.
Fix: sell prepaid packs, and count the balance in units, not money. The money turns into units at the moment of payment, so a later change of tariff never re-prices what someone already bought. And sell a quantity, never a calendar period: a month that expires while the agent is asleep is a month it paid for and never used.
Part 3. Calling out from Cloudflare Workers
14. A domain that does not exist returns a response, not an error
Symptom: a request to a dead domain looks like it reached a live server that crashed.
Cause: from a Worker, fetch to a non-existent hostname does not throw. It
returns a response with status 530. The naive rule “any 5xx means their app crashed” makes a dead
address look like a live server, and anything that treats an application error as “keep going” keeps calling a
vanished domain.
Fix: classify Cloudflare's edge codes as unreachable:
if ((status >= 520 && status <= 527) || status === 530) return 'unreachable';
15. “Three retries” can quietly mean nine
Cause: if you promise a maximum exposure per failing cycle, it is easy to count three attempts per delivery across three deliveries: nine charges, not three. The difference is invisible in code review because both versions contain the number 3.
Fix: count attempts, write the maximum exposure in money in your docs, and test it with a target that always times out.
16. A Worker cannot call another Worker's domain on the same account
Symptom: a test receiver you host yourself never receives anything.
Cause: Cloudflare refuses Worker-to-zone requests inside the same account with error
1042. This applies to every hostname on the account, including real custom domains, not only
workers.dev. Only what the edge serves itself (/cdn-cgi/*) answers.
Fix: put the control receiver at a real address outside your Cloudflare account. Any measurement of “what does a foreign zone see from me” is made against a foreign zone.
17. Every Worker leaves from the same IP address
Symptom: probes from a Worker to unrelated Cloudflare zones report the same source address, inside Cloudflare's published range.
const r = await fetch('https://www.cloudflare.com/cdn-cgi/trace');
// ip=2a06:98c0:3600::103 (run the same URL from your laptop as a control:
// it must show your own IP, or you are not measuring the source)
Why it matters twice:
- Anyone who rate-limits by IP in front of an API used by Workers puts a whole platform in one bucket. One abuser
there blocks every honest agent, and the block is usually an HTML page a machine cannot read. On Cloudflare's free
plan you cannot even write a rule on the address:
ip.srcis “not entitled” in rate-limiting expressions, and so are header fields. - Blocking all of
*.workers.devto protect yourself blocks your own customers, because agents running on Workers are a large part of the market.
18. A customer's header changes the result of your fetch without throwing
Symptom: measured against real workerd, with a POST and one extra
customer-supplied header:
| Header | Result |
|---|---|
expect: 100-continue | final status 100: not a 2xx, so it looks undelivered |
upgrade: websocket | 200 on a POST whose control returns 405: a “success” for a different request |
transfer-encoding, connection, te | no visible change |
Why it is expensive: if you bill per attempt, a customer's header breaks your outgoing request, and it looks like their server's fault.
Fix: refuse the whole hop-by-hop family when a customer registers custom headers, not at
send time: connection, keep-alive, transfer-encoding, te,
trailer, upgrade, expect, proxy-connection, plus
host and content-length. You might expect fetch to throw. It does not, which is
why the whole family goes, not a guessed handful.
19. A browser User-Agent from a Worker makes you more suspicious
Symptom: a feed behind Cloudflare returns zero items from a Worker for days, while from a laptop it returns 200 and a full list.
Cause: a Worker sending Mozilla/5.0 … Chrome looks like a bot pretending to
be a browser and gets a challenge (403, “Just a moment…”). The same request with no extra headers at
all gets 200. The browser User-Agent is usually added on purpose, to get past sites that block bot
User-Agents. It works in one direction and nobody tests the other.
Fix: a fallback, not a replacement. The first attempt stays as it is; the second, only
after a refusal, sends nothing extra. And log the refusal with its status. A function that returns []
for both “empty” and “refused” makes a week of silence invisible.
20. Public blockchain RPCs refuse Workers, and some return HTML with status 200
Symptom: a balance read that works from a laptop returns over rate limit
from a Worker on the first call of the day.
Cause: item 17 again. Public RPC endpoints limit by IP, and the shared address has used
up the quota before you ask. mainnet.base.org refused; base-rpc.publicnode.com answered from
the same Worker in the same minute.
Fix: a chain of two or three endpoints tried within one call, only on refusal, 5xx or rate limit, with the failing endpoint's address in the error text. Two traps inside the chain:
- Some endpoints answer HTTP 200 with
<!DOCTYPE. A status check passes and.json()throws. Classify it as a refusal and move on. - A read returns either a number or a reason, never “0 on failure”. A zero balance from a dead RPC makes a guard alert that the wallet is empty: a true alarm for the wrong reason.
21. D1 allows 100 bound parameters, and the code breaks on its own later
Cause: ORMs such as Drizzle bind one parameter per element, so inArray over
a list that grows with your data works until the day it has 101 elements. A multi-row INSERT binds one
parameter per column per row, so with five columns the real limit is 20 rows, not 100. The log shows a stack
trace without a message.
Fix: any list that grows with content goes through chunking, in batches of 50 (half the
limit, leaving room for the other bound values in the same WHERE), run sequentially. “There are only N
right now” is not a limit. It is the sentence that comes before every one of these bugs.
22. A partial unique index makes an endpoint return 500 on every call
Symptom: an endpoint that worked for months returns 500 on every call, with nothing but a stack trace, and nobody touched its code.
Cause: a migration narrowed a unique index to a partial one, and the code still says
ON CONFLICT(a, b). SQLite requires the conflict target to match the index including the
predicate, and refuses at prepare time: it does not fail on a conflict, it fails always.
CREATE UNIQUE INDEX t_ab ON t(a, b) WHERE state <> 'deleted';
INSERT INTO t ... ON CONFLICT(a, b) WHERE state <> 'deleted' DO UPDATE SET ...;
Check: three lines against the same engine. Mock databases pass because they model the text of the query, not SQLite:
import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync(':memory:');
db.exec(schemaFromYourMigrations);
db.prepare(upsertFromYourCode); // throws HERE if the target does not match an index
23. Every cleanup reports “0 rows deleted”, forever
Cause: D1 returns meta.changes. Drivers commonly used in tests return
rowsAffected. Code written against the test passes the test and reads undefined in
production.
Fix: read the count through one wrapper that understands both, and make every cleanup report its count per table. A cleanup that reports nothing is a cleanup nobody notices failing.
24. A rate limit that never fires
Symptom: a limit of 120 per minute, 140 requests sent at once, none refused.
Cause: the Workers Rate Limiting API counts per isolate. A burst spreads over isolates and each one sees about 14. With a limit of 5, four of twenty get through: the mechanism works, the number is too high for the way it is counted.
Fix: calibrate every limiter with an absurdly low value first and watch it fire, then set the real one. Cloudflare's edge rules count exactly, by contrast: at a limit of 5, twelve requests in three seconds give exactly five through. On the free plan they only offer a 10-second window, so they stop bursts and nothing else. And wait more than one window between two measurements, or you are measuring the previous run.
Part 4. Proving it still works
25. Zero real traffic means zero evidence
Symptom: health endpoint green, storefront open, pages loading, and the actual work path of the product not exercised by anyone. If it broke, the first to know would be the first real customer.
Fix: a canary: a real, small, scheduled job that goes through the whole path, end to end, against a receiver outside your infrastructure, with a guard that alerts on silence. Document it as permanent, so nobody deletes it as “leftover test data”. If it is ever removed, remove its guard in the same change. Otherwise the alarm rings for a decision made on purpose, and people learn to ignore it.
26. A rehearsal nobody stopped keeps running
Symptom: nothing. That is the point. A test job left over from a rehearsal can keep firing every few minutes for weeks. Health is green and nobody pays for it, except you.
Fix: a rehearsal is over only when someone ends it explicitly, and the ending is part of its plan. Test exceptions (for example a host allowed past a guard for the rehearsal) live in version-controlled configuration, never in a dashboard, and are reported publicly while they are set. An exception nobody can see stays forever.
27. A timer wakes up, does not know what to do, and goes back to sleep
Cause: a scheduled wake-up (a Durable Object alarm, a queued retry) needs context when it fires. If one path arms the timer without first writing that context, the timer fires, finds nothing, and returns quietly. No error, no trace, and the retry simply never happens.
Fix: every path that can arm a timer writes everything the timer needs, before arming it. A handler that finds nothing to do logs that loudly, because “nothing to do” should be impossible there.
28. Guards that are green because they cannot turn red
This is the root of everything above.
Three common forms:
- The test that passes for the wrong reason. “Refuses host X” stays green after host X is removed from the blocklist, because a different rule catches it.
- The completeness guard that counts itself. A scanner over the source files sits next to
expect(FILES.length).toBe(23), with a comment that a new file will turn it red. It cannot. The length is measured on the list, not on the directory. The fix is making the list be the directory (import.meta.glob('../../src/**/*.ts', …)in Vitest), with two stars: one star does not enter subfolders. - The fix whose test bypasses the fix. A calculation is fixed at the call site, and the test calls the pure function directly, with the corrected input typed by hand. It proves arithmetic that was already right. A mutation that reverts the call site stays green.
Fix: mutation, by hand, for every guard that protects money or delivery. Break the thing
on purpose, and break the seam (the place where the correct function is called), not only the function. Watch
the test go red. Break it in every branch where the fix was copied. And restore from a copy of the file, never with
git checkout: that returns to the last commit and silently deletes the uncommitted fix you are in the
middle of verifying.
The checklist
If you only take one thing from this page, take the list. Each line is one check that can fail.
- Decode your live
PAYMENT-REQUIREDheader with the buyer's library. It is present on refusals too. - Your first payment was made with
@x402/fetchor@x402/coreat default settings. - Selling is an explicit decision, including working settlement credentials, and when closed the service says why.
- Nothing that leaves the service contains text you did not mean to publish. A test renders every output.
- The one “we took your money” error says
retryable: false. extensions.bazaarhas a fullinfoandschema.- The CDP validator says
accepted, and a made-up URL saysrejectedin the same session. - Every listed resource has its own 30-day settlement clock and its own alert.
- You list the lowest entry price and the lowest unit price, not one row.
- After a catalog-visible change, you triggered the re-crawl and counted the rows.
- The OpenAPI bearer scheme is named
apiKeyand is stilltype: http, scheme: bearer. - The A2A card has
supportedInterfaces, lives at three paths, and something answers behind it. - You sell packs of units, never time.
- Status codes 520–527 and 530 from
fetchare classified as unreachable. - The retry limit counts attempts, and the maximum exposure is written in money.
- Your control receiver is outside your Cloudflare account.
- You do not rate-limit or block by the IP of Workers, and you do not block
workers.dev. - Hop-by-hop headers are refused when a customer registers them.
- Outgoing requests have a no-extra-headers fallback, and every refusal is logged with its status.
- RPC reads go through a chain, treat HTML as a refusal, and never return 0 on failure.
- Every list that grows with content is chunked below D1's 100 parameters.
- Every
ON CONFLICTis prepared against the real schema in SQLite after each index migration. - Deleted-row counts are read from
meta.changes. - Every rate limiter was first seen firing at an absurdly low value.
- A real canary job runs every day, and its guard alerts on silence.
- Every rehearsal has an explicit end, and every test exception is visible.
- Every path that arms a timer writes what the timer needs first.
- Every guard protecting money or delivery has been seen red by a deliberate mutation of its seam.
About 402cron
402cron is scheduled HTTP delivery for autonomous agents: pay with x402, register an HTTPS endpoint, and it is called on your schedule, signed. No account, no card, no human. Start at /docs or /llms.txt. The smallest pack costs $0.02 USDC. Prices are at /api/pricing, and there is an MCP server too.
Found a mistake, or a silent failure that belongs on this list? Write to the contact address on the home page.