The judgment endpoints charge per call. The transport is x402 v2, which means the entire payment handshake happens inside HTTP headers: no API key, no account, no invoice. This post documents the concrete wire format as served by pay.ashesborn.cloud, including the parts that are easy to get wrong when writing a client.

Everything in sections 2, 3, 6 and 7 was executed against the live service on 2026-10-28. The signing step in section 5 was not: it needs a funded wallet on Base mainnet, so that section is written from the specification and the challenge fields, and flagged as such.

1. What is paid, and in what units

GET https://pay.ashesborn.cloud/api/v1/datasets is the authority. Three entries, two of them judgments:

dataset_idToolPriceamount (atomic)
retraction-propagationretraction_propagation$0.0220000
retraction-evidence-auditintegrity_evidence_audit$0.10100000
web3-securityquery_graph0.02local/0.02 local / 0.10 global20000 / 100000

USDC on Base has six decimals, so 20000 is 0.02 USDC. Prices are quoted in atomic units everywhere in the protocol; a client that formats them as floats before signing produces an authorization with the wrong value and gets value_mismatch back.

The free paper-status route (GET /api/v1/judge/paper?doi=) is not part of this flow. It has no payment requirement at all.

2. The first request, and the challenge it returns

A request with no payment header returns 402 with an empty body. The challenge is in a header:

bash
curl -sS -D - -o /dev/null \
  -X POST https://pay.ashesborn.cloud/api/v1/judge \
  -H 'Content-Type: application/json' \
  -d '{"dataset_id":"retraction-propagation","doi":"10.1016/S0140-6736(97)11096-0"}'
text
HTTP/2 402
payment-required: eyJ4NDAyVmVyc2lvbiI6MiwicmVzb3VyY2UiOnsidXJsIjoiaHR0cHM6Ly9wYXku…
access-control-expose-headers: PAYMENT-REQUIRED, PAYMENT-RESPONSE

The header is base64url JSON. Decoded, our challenge is:

json
{
  "x402Version": 2,
  "resource": {
    "url": "https://pay.ashesborn.cloud/api/v1/judge",
    "description": "Deterministic analysis of retraction propagation through citations.",
    "mimeType": "application/json",
    "serviceName": "graphrag-ts",
    "tags": ["integrity", "retractions", "academic"]
  },
  "accepts": [
    {
      "scheme": "exact",
      "network": "eip155:8453",
      "amount": "20000",
      "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      "payTo": "0x5d44…8e29",
      "maxTimeoutSeconds": 60,
      "extra": { "name": "USDC", "version": "2" }
    }
  ],
  "extensions": { "bazaar": { "…": "input/output examples and a JSON Schema" } }
}

Four fields decide what the client signs: asset is the EIP-712 verifyingContract, extra.name and extra.version are the domain name and version, network in CAIP-2 form gives chainId (eip155:8453 is Base mainnet), and maxTimeoutSeconds bounds the authorization window. A client must not hardcode any of them; read them out of accepts[0].

The bazaar extension carries an example request and a JSON Schema for this endpoint, which is how a client can learn the body shape without human documentation.

3. The request body contract

The happy path requires dataset_id plus exactly one identifier:

json
{ "dataset_id": "retraction-propagation", "doi": "10.1016/S0140-6736(97)11096-0" }
{ "dataset_id": "retraction-propagation", "paper_id": "W2117847125" }

The endpoint rejects a body it cannot use before it asks for money, and the error names the expected fields instead of leaving the client to guess:

json
{
  "error": "invalid_request_body",
  "expected": {
    "dataset_id": "string, see GET /api/v1/datasets",
    "target": "exactly one of doi or paper_id"
  },
  "docs": "/api/v1/openapi.json"
}

GET /api/v1/openapi.json returns the full schema without authentication.

4. The payment payload

PAYMENT-SIGNATURE carries base64url JSON of the PaymentPayload object. For the exact scheme on EVM the shape is:

json
{
  "x402Version": 2,
  "resource": { "url": "…", "description": "…", "mimeType": "application/json" },
  "accepted": { "scheme": "exact", "network": "eip155:8453", "amount": "20000",
                "asset": "0x8335…2913", "payTo": "0x5d44…8e29",
                "maxTimeoutSeconds": 60, "extra": { "name": "USDC", "version": "2" } },
  "payload": {
    "signature": "0x…65 bytes…",
    "authorization": {
      "from": "0x…", "to": "0x5d44…8e29", "value": "20000",
      "validAfter": "1793172000", "validBefore": "1793172060",
      "nonce": "0x…32 bytes…"
    }
  }
}

accepted is echoed from the challenge; a mismatched amount, asset or payTo produces a recipient or value mismatch at verification time rather than a useful error, so copy the requirement object rather than rebuilding it.

The authorization is an EIP-3009 TransferWithAuthorization: a signature over the transfer terms, not a transaction. Nothing is broadcast until the server settles, and the payer's key never leaves the client.

5. Signing it

ts
import { toHex } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const challenge = JSON.parse(Buffer.from(paymentRequiredHeader, 'base64url').toString());
const req = challenge.accepts[0];

const now = Math.floor(Date.now() / 1000);
const authorization = {
  from: account.address,
  to: req.payTo,
  value: req.amount,
  validAfter: String(now - 60),
  validBefore: String(now + req.maxTimeoutSeconds),
  nonce: toHex(crypto.getRandomValues(new Uint8Array(32))),
};

const signature = await account.signTypedData({
  domain: {
    name: req.extra.name,                       // "USDC"
    version: req.extra.version,                 // "2"
    chainId: 8453,                              // from network eip155:8453
    verifyingContract: req.asset as `0x${string}`,
  },
  types: {
    TransferWithAuthorization: [
      { name: 'from', type: 'address' },
      { name: 'to', type: 'address' },
      { name: 'value', type: 'uint256' },
      { name: 'validAfter', type: 'uint256' },
      { name: 'validBefore', type: 'uint256' },
      { name: 'nonce', type: 'bytes32' },
    ],
  },
  primaryType: 'TransferWithAuthorization',
  message: {
    ...authorization,
    value: BigInt(req.amount),
    validAfter: BigInt(authorization.validAfter),
    validBefore: BigInt(authorization.validBefore),
  },
});

const payment = {
  x402Version: 2,
  resource: challenge.resource,
  accepted: req,
  payload: { signature, authorization },
};
const header = Buffer.from(JSON.stringify(payment)).toString('base64url');

Three details that break clients in practice:

  • value must be a string in the payload and a BigInt in the typed data. Mixing them changes the signed hash.
  • nonce is 32 random bytes presented as bytes32. Reusing one is a replay, and the ERC-20 rejects it at settlement, after the resource has already run.
  • validAfter a minute in the past is fine; validBefore beyond maxTimeoutSeconds is not, because the server enforces the advertised window.

If you would rather not hand-roll this, x402-fetch and x402-axios (both 1.2.x on npm) wrap a client with the same flow, taking the account as the payer.

6. What comes back, and the failure modes

On success the resource executes, settlement runs, and the receipt arrives in a second header:

text
HTTP/2 200
payment-response: eyJzdWNjZXNzIjp0cnVlLCJ0cmFuc2FjdGlvbiI6IjB4…

which decodes to { "success": true, "transaction": "0x…", "network": "eip155:8453", "payer": "0x…" }.

The error paths, measured against the live endpoint:

CaseStatusBody
No payment header402{} (challenge in PAYMENT-REQUIRED)
Body missing dataset_id400{"error":"invalid_request_body","expected":{…}}
dataset_id not in the catalogue404{"error":"Unknown judgment dataset."}
PAYMENT-SIGNATURE not base64400{"error":"Payment header is not valid base64."}
Header decodes but the payload fails the schema400{"error":"Payment payload is invalid."}
Payload well formed, signature forged402{"error":"invalid_exact_evm_payload_signature"}, with a fresh challenge

The two 400s and the 402 are three different problems with three different fixes. A 400 means the client built something the server cannot parse: no money has been discussed yet, and resending the same bytes returns the same 400. The 402 with a reason is a payload the server did read and handed to the facilitator, which rejected it and said why. invalid_exact_evm_payload_signature is the forged signature above, where the authorization was not signed by the key behind from. The specification's other verification outcomes travel through the same field, insufficient_funds and invalid_exact_evm_payload_authorization_valid_before among them, so a client can fan out on the string instead of probing. One naming detail costs an hour if it is missed: the reason arrives under error, while the invalidReason field that x402 client libraries read is absent from this response, so a wrapper that only looks at invalidReason reports a wrong clock as a network failure.

Ordering matters for idempotency: Idempotency-Key is accepted (it is in the CORS allow-list), so a retry that reuses the key does not double-charge if the first response was lost.

7. The same flow over MCP

The MCP server at mcp.ashesborn.cloud/mcp exposes three integrity tools. It answers initialize and tools/list anonymously, so a client can connect and inspect the inventory before any payment:

ToolAnonymous behaviour
integrity_paper_status200 with the status payload
retraction_propagationtool result with isError: true
integrity_evidence_audittool result with isError: true

The paid refusal is a normal JSON-RPC result, not a transport error, so a client that treats tool errors as "the model did something wrong" will hide it. The content is machine-readable:

json
{
  "error": "payment_required",
  "product": "retraction-propagation",
  "price": { "usd": "$0.02", "atomic": "20000" },
  "scheme": "x402",
  "network": "base",
  "pay_endpoint": "https://pay.ashesborn.cloud/api/v1/judge",
  "docs": "https://blog.ashesborn.cloud/datasets"
}

The MCP transport does not carry the HTTP payment handshake. The tool error names the product, the price and the pay_endpoint; the payment itself happens over HTTP with the flow in sections 2 to 5, and the resulting credential (issued as a key today) is what an authenticated session presents to call the paid tool. That handoff is not documented publicly yet, which is the practical gap for anyone wiring an agent to this surface: a client that never pays still gets the free status tool, and that is enough to answer "is this paper retracted" without a wallet.

8. What this does not cover

  • Settlement was not executed here. I decoded the challenge, built the payload and measured every error path, but did not broadcast a transfer: that needs a funded Base wallet. The signed-payload example follows the specification's field set and this deployment's challenge values; it has not been run end to end.
  • One network. Only eip155:8453 with USDC is offered. A client must read accepts and skip entries it cannot pay rather than assuming this one.
  • No subscription. Every call is a separate authorization. Budgets belong in the client; the server enforces only the per-request amount and window.