I wanted an agent of mine to resolve an Australian company by its ABN. That is a solved problem. There are several APIs that do it and they are cheap.
The agent could not use the ones I found.
The price was never the blocker. The signup was. Each of them wanted an email address, a confirmation click, a dashboard visit, a plan chosen, and a key copied out of a web page into an environment variable. That is a person at a browser, start to finish. An agent that hits that wall at 3am has no way through it, and the failure is quiet: it just reports that the data was not available and moves on.
So I built the other kind. No account, no key, no dashboard, no free tier. You ask, it tells you the price, you pay, you get the record. One cent a call over the ASIC company register, 4,008,800 companies.
Here is what the build actually taught me.
The payment is the credential
The mechanism is x402, which is HTTP 402 Payment Required finally being used for the thing its name says. Ask without paying and you get a machine-readable bill.
curl -i "https://api.nightshiftbuilds.com/v1/company?abn=33051775556"
HTTP/2 402
payment-required: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOiJwYXltZW50IHJlcXVpcmVkIi...
content-type: application/json
{
"x402Version": 1,
"error": "payment required",
"accepts": [{
"scheme": "exact",
"network": "base",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x<our receiving address>",
"resource": "https://api.nightshiftbuilds.com/v1/company",
"description": "One Australian company register lookup by ABN, ACN or name.",
"mimeType": "application/json",
"maxTimeoutSeconds": 300,
"extra": { "name": "USD Coin", "version": "2" },
"outputSchema": { "input": { "...": "..." }, "output": { "...": "..." } },
"maxAmountRequired": "10000",
"amount": "10000"
}]
}
10000 atomic units of USDC is USD 0.01. The client signs an EIP-3009 transferWithAuthorization for that amount to that address, base64s it into a PAYMENT-SIGNATURE header, and sends the identical request again. The server hands the signed authorisation to an x402 facilitator over HTTP, the facilitator verifies the signature and settles the transfer on Base, and the record comes back with the settlement hash in a PAYMENT-RESPONSE header.
Two calls from the client, four in total once the server's own verify and settle calls to the facilitator are counted, and no human anywhere in them. My server holds no private key, signs nothing and pays no gas. It can only check that money arrived.
Two details in that response are worth stealing.
The first is that there are two payment documents, not one. The JSON body is an x402 v1 challenge with network: "base". The base64 header is a v2 challenge with network: "eip155:8453" and a top-level resource object. Neither version is a superset of the other, so a document that tries to be both validates as neither. A v1 client reads the body and never learns the header exists. A v2 client reads the header and ignores the body. Both get something their own schema accepts.
The versions also disagree about where the price lives. v1 reads maxAmountRequired, v2 reads amount. Both documents carry both keys with the same value, which is not redundancy for its own sake: it means a client that is loose about which version it thinks it is speaking still reads the right price instead of finding nothing.
The second is that payments are checked against my terms, never the caller's. An x402 payment envelope carries a copy of the requirements it thinks it is satisfying, and that copy is caller-controlled. My first version forwarded it to the facilitator, which meant a caller could have been verified against their own address at their own price and been told they had paid. The envelope's copy is now checked for consistency and then thrown away. What goes to the facilitator is always my own published terms.
Being wire-correct with the client is not being wire-correct with the facilitator
The first real paid call failed. The client had signed correctly. My /verify call was coming back HTTP 400.
The facilitator's own openapi.json had the answer. Its PaymentPayload schema requires two fields to be objects under the v2 shape, and it rejected the envelope that a correct v2 client actually sends. The v1 request shape validated and returned a real verdict. The facilitator is now always addressed in v1 no matter which version the caller speaks, because what is being verified is the EIP-3009 authorisation and that is byte-identical either way. Only the network naming differs.
That split cost me the same amount of time again in a different form, and it is the finding I would most want to have been told in advance.
An agent finds an x402 API through a facilitator's catalogue. Facilitators index every resource they settle for and publish the index. Registration is automatic on first settlement, which I did not know, so my API had been sitting in one of those catalogues since its first payment and I had never looked. It was in there among 28,332 entries as a bare URL and a price, because a catalogue entry carries only what the seller publishes on the payment challenge, and I was publishing nothing.
So I published a discovery block on the v2 challenge extension. It reached clients. It never reached the catalogue.
The reason is the same one that fixed the 400. Settle forwards a v1-shaped document, and x402 v1 reads discovery from that document's paymentRequirements.outputSchema, which is the same field you can see as accepts[0].outputSchema on the 402 body above. Nothing in the v2 extension is in that document. The block was travelling to every client and nowhere else, and the failure mode is a silent gap in a catalogue that never tells you it has one. You can read your own entry with a public curl. Nothing prompts you to.
Both channels are published now. Two more silent traps sat behind that one. The facilitator validates the extension's info against a schema you supply and drops the whole extension if it fails, so a test now runs the same jsonschema check the facilitator runs. And the service name is read off the v2 resource object with a 32 character cap and soft-drop semantics, so "Australian company register lookup" at 34 characters would have vanished with nothing reporting why.
Charging honestly is mostly about what you do when you do not know
A query that matches nothing returns 404 and is not charged. The payment authorisation is released, so you can spend it on a different query. That one was easy and it is the right default for a lookup: nobody should pay a cent to learn that a string does not exist.
The hard one was the timeout. If the facilitator broadcasts the transfer and then the connection dies, the server has no idea whether money moved. My first version released the authorisation and told the caller charged: false. That is the worst possible answer. The honest retry then fails, because the nonce is already spent on chain, and the caller has lost the money and got nothing for it.
The payment authorisation nonce is now the idempotency key. The response is composed and stored before settlement is attempted, and a repeat of the same authorisation with the same query replays the stored answer instead of charging again. An ambiguous failure returns charged: "unknown", keeps the authorisation, and says plainly that money may have moved. The documented advice is to resend the identical request with the identical authorisation, never to sign a fresh one.
The retrieval is bound to both the query and the payer's signature, so a nonce someone else observed cannot be used to collect the data it bought. That last part came out of a review round, along with the discovery that {"success": "false"} from the facilitator was passing as a successful settlement, because a non-empty string is truthy in Python and I was checking truthiness instead of the boolean.
The whole thing
curl -i "https://api.nightshiftbuilds.com/v1/company?name=woolworths"
That is the entire onboarding. It costs one cent to go the rest of the way, and if you send it a name that does not exist it costs nothing. The agent guide is at /llms-full.txt and the OpenAPI document is at /openapi.json.
Contains ASIC Company Register data sourced from data.gov.au, © Australian Securities and Investments Commission, licensed under CC BY 3.0 AU. It is a weekly snapshot and it is not the official register. ASIC Connect is authoritative.
Top comments (0)