One-time payments
A signed intent, a transaction the buyer sends from their own wallet, and a server-side verification that decides whether you fulfil.
A one-time payment is a signed intent, a transaction the buyer sends, and a server-side verification. P2Flux stores none of it: the intent carries its own contents, and verification re-reads the chain.
Create a payment
Mint a payment intent. Unknown fields are rejected outright.
There is deliberately no reference field. P2Flux generates it, so order ids, customer ids and emails cannot be pushed into the payment layer even by accident.
Who pays the network fee
By default the buyer sends the payment transaction and pays the chain’s network fee in its native currency — ETH on Base. That is gas_payment_mode: 'native', it is what happens when you send no such field, and its fees are unchanged.
With gas_payment_mode: 'payment_token' the buyer needs no ETH at all. They sign one authorization; P2Flux submits the transaction and pays the ETH, and the buyer reimburses that cost in USDC, inside the same transaction; the merchant-funded fixed network fee of 0.10 USDC comes out of the amount, as on a subscription. If the transaction fails, both halves fail: the buyer is charged nothing.
GET /v1/capabilities says which networks, tokens and operations support this. Architectural possibility is not support: a token that implements the right standards on a network P2Flux has not deployed to reports false, and every request for it is refused deterministically.
A quote expires. That same instant is inside the buyer’s signature, so a stale quote cannot be executed by anyone — if it lapses, ask for a fresh one and let the buyer sign again. The quoted fee is a price agreed in advance, not a measurement of the gas the transaction turns out to use; what it actually cost is reported separately for reconciliation.
Resolve
Authoritative terms for a checkout to display.
The hosted checkout calls this; you only need it if you are building your own payment page. It exists because a browser must never trust what it can decode out of a token itself — a tampered intent has to fail here, before anyone is shown or asked to sign anything.
It also asks the contract whether the payment is still payable, so a buyer is never sent to their wallet for a transaction that would revert.
The response includes confirmations_required — how deep the server will require the transaction to be before verification says valid. A checkout can wait that depth out on its own RPC and then verify once, instead of polling while the blocks accumulate; the hosted checkout does exactly that. Advisory only: the server applies the policy itself regardless.
Present the payment
Open the hosted checkout at <checkout>/#/pay/<intent>. The intent travels in the URL fragment — the part after the #. Browsers do not put a fragment in the HTTP request or in the Referer header, so it does not reach the server or its access logs, and the checkout clears it from the address bar once it has read it. It is still a value present in the page: treat it as a secret with respect to anything else running there, and keep it out of client-side analytics and error reporting.
The checkout reports back to the opening page by postMessage. Check the origin, and treat the message as a prompt to verify rather than as proof of payment.
Verify
Server-side proof that a payment landed. Never skip it.
Takes the intent and the tx_hash. Everything is checked against the receipt: the settlement id derived from token, recipient, amount and reference; the recipient and token actually paid; that net plus fee equals the signed amount; that the fee is exactly 1%; that the USDC transfers happened; and that the transaction is three confirmations deep.
It optionally takes a third field, settlement_receipt — the sealed token a previous successful verification of this same payment returned, couriered to your page by the hosted checkout in the p2flux.payment.completed message. Presenting it lets the server answer immediately without re-reading the chain. It is opaque, short-lived, and bound to exactly this intent and transaction on this network: a missing, expired, tampered or mismatched receipt is silently ignored and the full verification runs instead. Always safe to pass along whatever the browser handed you — the server, never the receipt, is the authority.
This endpoint does not signal outcomes with HTTP status codes. Every answer it reaches — success or not — is HTTP 200, carrying either the success object or { "valid": false, "code": … }. Branch on valid, not on the status code. Only transport-level failures differ: a malformed body is 400, too many requests is 429, and an unexpected server fault is 500.
A successful verification returns valid: true with tx_hash, reference, amount, block_number, block_hash and the settlement_receipt described above. Anything else returns valid: false and one of these codes:
What PAYMENT_CONFIRMING means
It means the payment cannot yet be proved — not that it succeeded. One code covers two situations the API cannot tell apart from the outside:
- A transaction exists and is mined, but is not yet three confirmations deep.
- No receipt has been seen for that hash at all — it may be pending, the node answering may not have caught up, or no such transaction may ever have been broadcast.
Absence of a receipt is deliberately treated as “keep waiting” rather than “nothing happened”, because telling someone who has just paid that their transaction does not exist is the worse of the two mistakes. That is precisely why the code must not be read as confirmation of payment.
Do not fulfil until you get valid: true. While the answer is PAYMENT_CONFIRMING, keep the same tx_hash and verify again — do not send the buyer back to checkout, and do not create a second intent. A new intent carries a new reference, which the contract will settle a second time.
Confirming answers carry a Retry-After header naming the sensible pause (a few seconds — settlement on Base takes seconds, not minutes). Honour it: the server also remembers its last verdict per payment for a short window, so re-asking faster than the header just returns the same answer from memory.
Expiry
An intent expires one hour after creation by default. Expiry governs starting a payment: after it, /v1/payments terms can no longer be resolved and a checkout will not open with that intent. Create the intent when the buyer is ready to pay, not when the cart is built.
Verification and refunds deliberately ignore the intent’s expiry. A transaction that settled while the intent was live is an irreversible fact and stays true an hour later, so a payment broadcast in the last seconds of an intent’s life and confirmed after it can still be verified and still be refunded. Everything else about the token is checked exactly as strictly — the signature, the deployment binding, the field shapes — because it is the signature that proves the terms, and signatures do not expire.
Replay protection
The contract records each settlement by an id derived from the token, recipient, amount and reference, and refuses a second one. Because the reference is fresh per intent, the same buyer can re-order the same item at the same price as often as they like — but one receipt can never be used to verify two orders.
If the browser callback never arrives
The p2flux.payment.completed message is how you normally learn the transaction hash. It is a browser event, so it can be lost: the buyer closes the popup, the tab crashes, the phone locks, the connection drops between signing and delivery.
Find the settling transaction from the intent alone.
Send the intent and nothing else — no hash, no hint. P2Flux searches the chain for the settlement that intent describes and verifies anything it finds the ordinary way.
Recovery can search a wide range of blocks, which makes it the most expensive call this API offers. It runs at low priority, so recovering yesterday’s payment gives way to taking today’s, and it is limited to 30 calls a minute per caller and 6 per payment. Use it when a callback is missing — the normal flow is still checkout, transaction hash, verify, and recovery is not a substitute for it.
Two things still make this smoother, and both are your side of the integration: persist the intent and reference against the order before the buyer is sent anywhere — recovery needs the intent — and never respond to a missing callback by creating a second intent. The replay guard protects the first reference, not a new one, so a second intent is a second payment the buyer can genuinely be charged for.
Refunds
Supported, as a transfer from your wallet back to the payer — P2Flux derives the terms from the original settlement and verifies the result, but never holds or sends the money. Partial amounts are allowed. Full detail in Refunds.
A completed blockchain payment cannot be undone. A refund is a second payment going the other way, which is why it costs you gas and why P2Flux cannot make it happen on your behalf.
P2Flux keeps no global refund ledger, so nothing in the API prevents a second refund of the same payment. Your integration has to. See Refund idempotency.