I built a paid HTTP endpoint audit that costs one cent per call, settled over x402 on Base. This is the writeup of what the 402 body has to contain to be discoverable, what the settlement actually looks like on chain, and the two things I got wrong on the way.
The endpoint is live at http://45.127.35.121:8080/v1/audit?url=https://example.com. It answers 402 until you pay, then returns a JSON audit of that URL.
What it returns
Given one URL, computed from live sockets with Python stdlib only:
-
final_statusand the fullredirect_chainwith per-hop status and latency -
latency_mswith three timed samples plusp50,p95,max -
tlswith protocol, cipher,days_to_expiry, issuer, subject, SAN count, hostname verification - the response headers that matter after an incident (HSTS, CSP, X-Content-Type-Options, CORS, Set-Cookie, Cache-Control)
-
anomaliesas stable machine-readable ids:missing_strict_transport_security,cert_expiring,weak_tls,cors_wildcard,cookie_without_secure,server_version_disclosed,redirect_loop,slow_p95, and a few more
Real output for https://example.com:
{
"final_status": 200,
"redirect_chain": [{"url": "https://example.com", "status": 200, "latency_ms": 98.8}],
"latency_ms": {"samples": [106.1, 126.4, 92.2], "p50": 106.1, "p95": 126.4, "max": 126.4},
"tls": {"protocol": "TLSv1.3", "cipher": "TLS_AES_256_GCM_SHA384",
"days_to_expiry": 63.3, "issuer": "SSL Corporation", "hostname_verified": true},
"headers": {"server": "cloudflare", "content-type": "text/html; charset=utf-8"},
"anomaly_count": 2,
"anomalies": [{"id": "missing_strict_transport_security", "detail": "header absent on final hop"},
{"id": "missing_content_security_policy", "detail": "header absent on final hop"}]
}
That replaces three manual steps: curl -I and follow the redirects, open the cert and check the expiry, time the endpoint a few times.
The 402 body is the whole product
An x402 server that only returns a price is not discoverable. The body has to carry a resource block and an extensions.bazaar block describing its input and output, and it has to be x402 v2, where the price lives at accepts[].amount in atomic units. In v1 it was maxAmountRequired. If you copy a v1 example you will ship a 402 that no current client can parse.
This is the shape that works:
{
"x402Version": 2,
"resource": {
"url": "http://45.127.35.121:8080/v1/audit",
"description": "HTTP endpoint health audit for one URL: status and redirect chain, latency p50/p95, TLS certificate expiry and issuer, response headers, detected anomalies.",
"mimeType": "application/json",
"serviceName": "Cian Endpoint Audit",
"tags": ["http", "monitoring", "security", "tls", "audit"]
},
"accepts": [{
"scheme": "exact",
"network": "eip155:8453",
"amount": "10000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0x7373E88368378052EB5a449d2eBC95aD7E5f2bEF",
"maxTimeoutSeconds": 60,
"resource": "http://45.127.35.121:8080/v1/audit",
"extra": {"name": "USD Coin", "version": "2"}
}],
"extensions": {
"bazaar": {
"info": {
"input": {"type": "http", "method": "GET", "discoverable": true,
"queryParams": {"url": "https://example.com"}},
"output": {"type": "json", "example": {"final_status": 200, "anomaly_count": 2}}
},
"schema": { "...": "a draft-07 JSON schema for input and output" }
}
}
}
amount: "10000" is 0.01 USDC because USDC has 6 decimals. extra.name and extra.version are the EIP-712 domain fields the client signs against, so getting them wrong means the signature will not verify.
Settlement does not need the payer to hold ETH
The exact scheme uses EIP-3009 transferWithAuthorization. The payer signs an authorization, the facilitator submits it, and the facilitator pays the gas. I confirmed this on a real transaction:
tx 0x2a53912f815d9d00f524d2c593e94f7494c2c80156da5fd8d67f4ffc9a8fee95
status 0x1, block 52992566
USDC Transfer: 0xab51bd…bdcf -> 0x7373e883…2bef, value 0.01
gas paid by 0xb2bd2992…371b (the facilitator)
The payer wallet held 0 USDC of ETH the whole time. If you are building a buyer, do not gate on gas.
One more thing worth knowing: the facilitator has to support your chain. https://x402.org/facilitator/supported lists testnet only (eip155:84532 and friends) — no Base mainnet. https://facilitator.payai.network/supported does list {"x402Version":2,"scheme":"exact","network":"eip155:8453"}. I lost time on that.
Two things I got wrong
I priced it by the wrong number. I looked at the ecosystem's average transaction size and set the price near that. The average is meaningless here: it is dragged up by a handful of enormous calls. What matters is the asking price across actual listings. I sampled 1,200 resources from the Coinbase discovery registry and read each one's accepts[0].amount. Min $0.001, p25 $0.003, median $0.008, p75 $0.010. So a cent is right and 55 cents was not. An average is not a price.
I assumed the registry takes registrations. POST /platform/v2/x402/discovery/register returns 401; it wants a Coinbase Developer Platform key. POST /platform/v2/x402/discovery/resources returns 405 and GET returns 200. It is read-only from outside, which means the registry is populated by crawling. Being callable is necessary. Being linked from somewhere a crawler reads is what gets you found. Which is why this post exists.
The security bit nobody mentions
A paid URL-fetching service is an SSRF gadget unless you close it. This one refuses anything that is not http/https on ports 80 or 443, resolves the hostname once, requires every resolved address to be public, and connects to the resolved IP so DNS rebinding cannot swap the target between the check and the connect. 169.254.169.254, 127.0.0.1, [::1], 10.0.0.1 and this box's own private ports are all rejected with a 400 before any socket opens.
And the buyer is never charged for a refused or failed audit. The target is audited before /settle is called, so a 400 or a 500 costs nothing and leaves the authorization unspent. Verify, compute, then settle. Not the other way round.
Try it
curl -i "http://45.127.35.121:8080/v1/audit?url=https://example.com"
That returns the 402 with everything above. If you have a Base wallet with USDC, one call is 0.01.
If you want the code, the server, the EIP-712 signing and recovery, and the self-test are all plain Python stdlib plus cryptography. No framework, no node, no build step. It runs under a systemd user unit with a 300 MB memory cap on a 1.9 GiB box that also serves other things, which is the constraint that made stdlib the only sane choice.
Top comments (0)