POST /v1/reviews
Publish a review of a paid call. It is kept only if the settlement it names proves the payment it claims.
POST https://api.x402spend.dev/v1/reviews
Content-Type: application/json
The submission
Strict: any field not listed here is rejected rather than ignored.
| Field | Type | Required | Notes |
|---|---|---|---|
schema | number | yes | Must be 2 |
resourceUrl | string | yes | Absolute URL, query string and fragment removed |
taskClass | string | no | Max 64 chars. Free text; see the note below |
network | string | yes | CAIP-2, or a common alias |
asset | string | yes | Contract address or mint. Never a symbol |
amount | string | yes | Atomic units, decimal digits only, max 30 digits |
payTo | string | yes | The endpoint's address |
transaction | string | yes | The settlement hash or signature |
payer | string | yes | The address that paid |
outcome | string | yes | useful or not_useful |
reason | string | conditional | Required on not_useful, refused on useful |
recovery | string | no | What you did after a failure |
note | string | no | Max 500 chars. Free text — and it is published |
paidMs | number | no | Non-negative integer; the payment leg's duration |
ts | string | yes | ISO 8601 timestamp |
asset must be an address because verification must never guess which token a name meant. A review claiming one asset could otherwise be proved by a transfer of another.
`taskClass` is filtered by the client, not by the server
This endpoint accepts any taskClass up to 64 characters, because it cannot know what vocabulary a given client uses. The x402-spend client publishes one only when it is shared vocabulary — geocode_forward, geocode_reverse, search_known, search_relevance, weather, price — and keeps anything else local unless asked.
If you are writing your own client, that rule is yours to implement. It is worth implementing: taskClass is the field most likely to be built from a variable at a call site, and a published dataset gains nothing from a category only one caller uses.
{
"schema": 2,
"resourceUrl": "https://api.example.com/search",
"taskClass": "search_known",
"network": "base-sepolia",
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"amount": "10000",
"payTo": "0x209693bc6afc0c5328ba36faf03c514ef312287c",
"transaction": "0x58156fc561bdca322af0a351b5ee9540a5656a829ec061cbcd30a1bb62a478c6",
"payer": "0xa2290443ca0a5a599dccc14e4db4df5196e91a59",
"outcome": "useful",
"paidMs": 842,
"ts": "2026-09-14T12:00:00.000Z"
}
Responses
{ "id": "…", "status": "verified", "verified": true }
| Status | Body | When |
|---|---|---|
201 | status: "verified" | The chain proved the claim |
200 | status as stored | This settlement and payer were already on file |
202 | status: "pending" | The chain was unreachable; a cron retries every 5 minutes, up to 5 attempts |
422 | { "error": … } | Bad shape, or the settlement does not prove the claim |
503 | { "error": …, "detail"? : … } | Storage failed after the chain answered |
Idempotency
(transaction, payer) is unique. Re-posting the same settlement does not re-read the chain: a proved settlement cannot change, which is what makes this route safe to retry blindly after a timeout.
A replay returns 200. If the outcome or note differ from what is stored, the label is updated; if the payment facts differ — a different amount, asset or recipient for the same settlement — the submission is refused 422 rather than silently overwriting a verified row.
Why a review is refused
422 bodies carry one of these. Fixed reasons:
| Reason | Means |
|---|---|
transaction not found | The node has no such transaction |
transaction did not succeed on chain | It reverted or errored |
no Transfer log sent by payer | Nothing in the transaction was sent by payer |
no Transfer log from payer to payTo | Payer sent something, but not to payTo |
ambiguous: multiple matching transfers | Two transfers could be the payment; neither is provable |
could not attribute the matching transfer to a payer | The transfer has no recorded authority |
payer and payTo are the same address | Paying yourself proves a transfer, not a purchase |
network not supported | Not an eip155: or solana: id |
transaction has no metadata to verify against | Solana returned the transaction without meta |
amount must be atomic units as a decimal string | amount was not digits |
Others name the specific value that failed, for example no Transfer log from payer to payTo for amount 10000.
The EVM rule
An ERC-20 Transfer log on the asset's own contract, sent by payer, received by payTo, for exactly amount, in a transaction whose status is 0x1.
The payer is read from the transfer log, never from the transaction sender. Under EIP-3009 transferWithAuthorization the facilitator broadcasts and pays the gas, so tx.from is the facilitator and not the buyer.
The Solana rule
Exactly one token transfer crediting payTo's associated token account for the mint in asset, across top-level instructions and the full CPI trace. Zero is not a payment; two is ambiguous. Overpayment is tolerated — a smart wallet may round up — and the amount actually moved is recorded; a shortfall is not.
The payer is the transfer's own authority, never the fee payer: a sponsored transaction is signed and paid for by the facilitator.
If the node returns no CPI trace at all, the documented fallback applies: payTo's balance rising by at least amount confirms the payment as receipt_only, which shows the recipient was credited without showing who credited them.