{ "openapi": "3.1.0", "info": { "title": "P2Flux API", "version": "1.0.0", "summary": "Non-custodial USDC payments, subscriptions and refunds on Base.", "description": "Programmable, non-custodial payments on Base. Money moves **buyer wallet to recipient wallet**, with the P2Flux fee\nsplit out in the same transaction. There is no custody, no balances and no accounts: P2Flux never holds funds and\nnever becomes an intermediary that could.\n\n## Authentication\n\nThere is none, deliberately. A P2Flux request carries no ambient authority - no cookies, no `Authorization` header,\nno credentialed CORS. **The signed capability in the request body is what authorizes the call**, and it is bound to\nan exact recipient, amount and period. Treat every capability as a bearer secret: keep it server-side, encrypted at\nrest, and out of URLs and logs.\n\n## What P2Flux stores about you: nothing\n\nCustomer identity, orders, products, prices, subscription lifecycle and business records stay in **your** database.\nP2Flux handles only the protocol information a payment needs - addresses, amounts, a random reference, a signature.\nThe `reference` is generated by P2Flux and never accepted from a caller, so a shop cannot put an order id or an\nemail address into it by accident. Keep your own `order -> reference` mapping; there is no P2Flux dashboard that\nknows what was bought.\n\n## Amounts\n\nTwo representations, never interchangeable:\n\n- **decimal strings** (`\"10.00\"`) where a human reads the value;\n- **micro-USDC integer strings** (`\"10000000\"`) where arithmetic happens - refund amounts especially.\n\nNever a JSON number. Binary floating point cannot represent most decimal prices exactly, and this is money.\n\n## Reading a result\n\nBranch on the `error` code and the `action`, never on the HTTP status alone. Two results deserve singling out:\n\n- **`WAIT`** (`PAYMENT_CONFIRMING`, `REFUND_CONFIRMING`, `CONFIRMING`) means the transaction exists and the money may\n already have moved. Poll the same transaction. Starting another one is how a customer pays or gets refunded twice.\n- **`PAYMENT_NOT_FOUND`** from recovery is an as-of-this-block statement, never a permanent verdict.\n\nUnknown request fields are **rejected**, not ignored: a body with a typo'd key is a 400 rather than a silently\ndifferent request. Errors that are worth retrying carry a `retry-after` header.\n\n## Environments\n\n| | API | Hosted checkout | Chain |\n|---|---|---|---|\n| Test | `api-test.p2flux.com` | `pay-test.p2flux.com` | Base Sepolia (84532) |\n| Production | `api.p2flux.com` | `pay.p2flux.com` | Base Mainnet (8453) |\n\nThe two are complete, non-interchangeable stacks: a capability issued by one is refused by the other, by\nconstruction. **Production is live on Base Mainnet** and open to integrate against - real money, no reversals.\nThe test environment is the same API on Base Sepolia; build and experiment there without real value at stake.\nLive state is on the [status page](https://p2flux.com/status.html).", "contact": { "name": "P2Flux", "url": "https://p2flux.com/docs/" }, "license": { "name": "Documentation for the hosted P2Flux API", "url": "https://p2flux.com/terms.html" } }, "servers": [ { "url": "https://api.p2flux.com", "description": "Production - Base Mainnet (8453). Real money: every settlement moves real USDC and cannot be reversed by P2Flux. Point your integration here; use the test server for experiments." }, { "url": "https://api-test.p2flux.com", "description": "Test - Base Sepolia (84532). Identical API against the test deployment; value moved here is faucet USDC, not real money. The interactive explorer is restricted to this server by design." } ], "externalDocs": { "description": "Guides, flows and worked examples", "url": "https://p2flux.com/docs/" }, "tags": [ { "name": "One-time payments", "description": "A single payment to a single recipient." }, { "name": "Recurring payments", "description": "Signed once by the customer, charged by your renewal job." }, { "name": "Refunds", "description": "A transfer from the merchant's own wallet back to the wallet that paid." }, { "name": "Cancellation", "description": "Unsigned calldata for the customer's own wallet to send." }, { "name": "Service", "description": "Liveness." } ], "paths": { "/health": { "get": { "operationId": "health", "summary": "Liveness", "tags": [ "Service" ], "description": "The one endpoint the whole internet can reach, so it says that the process is up and nothing else - no balances, no counters, no version. Not rate limited. It is not a readiness check: operational state is internal and deliberately not published.", "responses": { "200": { "description": "The process is up.", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "const": true } } }, "examples": { "up": { "value": { "ok": true } } } } } } } } }, "/v1/allowances/restore/resolve": { "post": { "operationId": "resolveAllowanceRestore", "summary": "Read an allowance-restore session back", "tags": [ "Recurring payments" ], "description": "For the browser holding the session: the terms of the approval to ask for. Terms only - the transaction is the customer's own standard ERC-20 `approve()`, which their wallet builds and sends.\n\nThe spender is P2Flux's recurring contract, taken from the signed session. A screen that accepted a spender from its own input would be a phishing page.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "required": [ "approve_token" ], "properties": { "approve_token": { "$ref": "#/components/schemas/Token" }, "gas_payment_mode": { "type": "string", "enum": [ "native", "payment_token" ], "default": "native", "description": "With `payment_token` the response also carries a price and the two messages the customer signs, and P2Flux sends the transaction. Without it the response describes the customer's own `approve()`, as before." }, "allowance_units": { "type": "string", "enum": [ "0" ], "description": "Only `\"0\"` is accepted, and it prices a REMOVAL of the allowance instead of a restore. The quote returned is bound to that operation: a removal quote is refused on a restore submit and vice versa. Absent means \"restore what this subscription needs\"." } } } } } }, "responses": { "200": { "description": "What to approve, and who must approve it.", "content": { "application/json": { "schema": { "type": "object", "properties": { "chain_id": { "type": "integer" }, "token": { "$ref": "#/components/schemas/Address" }, "spender": { "$ref": "#/components/schemas/Address" }, "payer": { "$ref": "#/components/schemas/Address" }, "subscription_id": { "$ref": "#/components/schemas/Bytes32" }, "required_units": { "$ref": "#/components/schemas/AmountUnits", "description": "The signed amount plus the gas reimbursement the next charge may add. An allowance covering only the amount stalls on the reimbursement." }, "expires_at": { "type": "integer" }, "approve_units": { "type": [ "string", "null" ], "description": "What to approve, in base units; null means unlimited. The subscription's own setup mode, so a bounded allowance is not widened on repair." } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_CANCEL_TOKEN`, `CANCEL_TOKEN_EXPIRED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Rate limited per IP.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/allowances/restore/session": { "post": { "operationId": "createAllowanceRestoreSession", "summary": "Session for restoring the allowance one subscription needs", "tags": [ "Recurring payments" ], "description": "`INSUFFICIENT_ALLOWANCE` is not a dead subscription. The authorization the customer signed is intact and the seller can still collect; what ran short is the ERC-20 allowance, and the fix is one `approve()` from the customer's own wallet - no new signature, no new subscription.\n\nThis exchanges the capability for the narrowest browser-facing token P2Flux issues: the payer, the spender (the recurring contract), the token and how much the next charge will pull. It carries no authorization struct and no signature, so it cannot be turned into a capability, cannot build `revoke()` and cannot prepare a refund. Hand it to the customer at `/#/approve/`.\n\nShort-lived by design - it only has to survive the walk from your account page to a wallet confirmation.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "required": [ "subscription" ], "properties": { "subscription": { "$ref": "#/components/schemas/Token" } } } } } }, "responses": { "200": { "description": "The session, and who must send the approval.", "content": { "application/json": { "schema": { "type": "object", "properties": { "approve_token": { "$ref": "#/components/schemas/Token" }, "expires_at": { "type": "integer", "description": "Unix seconds." }, "payer": { "$ref": "#/components/schemas/Address" }, "subscription_id": { "$ref": "#/components/schemas/Bytes32" } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Rate limited per IP.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/allowances/revoke/prepare": { "post": { "operationId": "prepareAllowanceRevocation", "summary": "Calldata that stops every P2Flux subscription", "tags": [ "Cancellation" ], "description": "Sets the token allowance to zero - the customer's blunt instrument. It stops every P2Flux subscription for that wallet at once, not just one. Their wallet sends it.", "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": {} } } } }, "responses": { "200": { "description": "Unsigned calldata.", "content": { "application/json": { "schema": { "type": "object", "properties": { "chain_id": { "type": "integer" }, "to": { "$ref": "#/components/schemas/Address" }, "data": { "type": "string" }, "description": { "type": "string" } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_REQUEST`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/charges": { "post": { "operationId": "charge", "summary": "Execute one recurring charge", "tags": [ "Recurring payments" ], "description": "Collect one billing period. Call it from your own renewal job - P2Flux schedules nothing.\n\n**Safe to retry.** The contract allows one charge per period, so a repeat call after a timeout or a crash returns `ALREADY_CHARGED` rather than charging again. Treat that as success.\n\n**`CONFIRMING` is not a failure.** The transaction is on chain and not yet settled: leave the period open, change nothing, and ask again in a few seconds about the same transaction. Its `action` is `WAIT`, and it is the one result that must never be shown to a customer as an error - they have paid.\n\nEvery other outcome carries an `action` telling you what to do; the retry SCHEDULE (an hour, a day, a dunning email) is your business policy, not ours.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "subscription": { "$ref": "#/components/schemas/Token" } }, "required": [ "subscription" ] } } } }, "responses": { "200": { "description": "The charge outcome.", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "enum": [ "CHARGED", "ALREADY_CHARGED", "CONFIRMING" ] }, "ok": { "type": "boolean", "description": "True for CHARGED and ALREADY_CHARGED - both mean the period is paid." }, "already_paid": { "type": "boolean" }, "action": { "$ref": "#/components/schemas/MerchantAction" }, "tx_hash": { "$ref": "#/components/schemas/Bytes32" }, "subscription_id": { "$ref": "#/components/schemas/Bytes32" }, "amount": { "$ref": "#/components/schemas/Amount" }, "period_index": { "type": "integer" }, "next_period_at": { "type": "string" } } }, "examples": { "charged": { "summary": "Collected", "value": { "status": "CHARGED", "ok": true, "already_paid": false, "action": "SUCCESS", "tx_hash": "0x3f63e790571cc0cc6e0e4efee1954e034022a949f0b120127bdd25beedca2371", "amount": "10.000000", "period_index": 0 } }, "confirming": { "summary": "Sent, not settled - wait, never re-charge", "value": { "status": "CONFIRMING", "ok": false, "already_paid": false, "action": "WAIT", "tx_hash": "0x3f63e790571cc0cc6e0e4efee1954e034022a949f0b120127bdd25beedca2371", "period_index": 0 } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`, `PERMISSION_REVOKED`, `INSUFFICIENT_BALANCE`, `INSUFFICIENT_ALLOWANCE`, `TRANSACTION_REVERTED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "Refused. `error` names which of: `NOT_DUE`, `SUBSCRIPTION_EXPIRED`, `GAS_TOO_HIGH`, `PAYMENT_ALREADY_PROCESSED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Refused. `error` names which of: `RATE_LIMITED`, `CONCURRENCY_LIMIT`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Refused. `error` names which of: `GAS_QUOTE_UNAVAILABLE`, `RELAYER_ERROR`, `RPC_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Operator-side limits, not payment outcomes: nothing was spent and the subscription is untouched. Retry later; a customer can do nothing about a gas spike.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/charges/recover": { "post": { "operationId": "recoverCharge", "summary": "Find the transaction that charged one recurring period", "tags": [ "Recurring payments" ], "description": "For when a charge landed and its response did not: your retry answers `ALREADY_CHARGED`, which proves the period was collected and names no transaction. P2Flux stores nothing, so the hash lives only in the contract's log - and without it you cannot attribute the payment to an order, audit it, or refund it, because refunds start from the original settlement.\n\nGive it the subscription and the exact period. There is no 'current period' form on purpose: you are reconciling one specific collection, today or in a year, and the answer must not move under you.\n\n**The event is the proof.** A settlement is returned only when the contract's own `SubscriptionCharged` log names this subscription AND this period, and only when its payer, recipient and amount match the signed authorization.\n\n**`found: false` is ordinary.** There is no catch-up billing, so a period that was never collected is a normal history: a later period having been charged says nothing about an earlier one. Like the one-time recovery, a miss is a statement about one block height and never a permanent verdict.\n\nA `hint` is an optimisation and nothing else - it narrows where the search starts, can never turn a miss into a hit, and is safe to omit.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "required": [ "subscription", "period_index" ], "properties": { "subscription": { "$ref": "#/components/schemas/Token" }, "period_index": { "type": "integer", "minimum": 0, "description": "The exact billing period to reconcile, as returned by /v1/charges or /v1/subscriptions/status." }, "hint": { "type": "object", "additionalProperties": false, "description": "Where your own records say you attempted the charge. Narrows the search; never evidence.", "properties": { "attempted_at": { "type": "integer", "minimum": 0, "description": "Unix seconds." }, "block": { "type": "integer", "minimum": 0 } } } } } } } }, "responses": { "200": { "description": "Either the located settlement, or a not-found that names the block it was true at.", "content": { "application/json": { "schema": { "type": "object", "required": [ "found" ], "properties": { "found": { "type": "boolean" }, "subscription_id": { "$ref": "#/components/schemas/Bytes32" }, "period_index": { "type": "integer" }, "tx_hash": { "$ref": "#/components/schemas/Bytes32" }, "block_number": { "type": "string" }, "payer": { "$ref": "#/components/schemas/Address" }, "recipient": { "$ref": "#/components/schemas/Address" }, "net_units": { "$ref": "#/components/schemas/AmountUnits" }, "fee_units": { "$ref": "#/components/schemas/AmountUnits" }, "network_fee_units": { "$ref": "#/components/schemas/AmountUnits" }, "amount_units": { "$ref": "#/components/schemas/AmountUnits", "description": "net + fee + networkFee: the amount the authorization signed for." }, "code": { "$ref": "#/components/schemas/ErrorCode" }, "as_of_block": { "type": "string", "description": "Only on a miss. The head this answer was computed at." } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`, `INVALID_REQUEST`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "`PAYMENT_CONFIRMING` - the settlement exists and is not yet deep enough to act on. The transaction hash rides along; ask again about that same one.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Limited per IP and per subscription - reconciliation is a background job, not a poll loop.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "`PAYMENT_RECOVERY_INCONSISTENT` (a log that contradicts the signed terms) or `RPC_ERROR`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "`RECOVERY_UNAVAILABLE` - the search could not be completed within its bounded budget on this deployment. Retryable.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/payments": { "post": { "operationId": "createPayment", "summary": "Create a one-time payment intent", "tags": [ "One-time payments" ], "description": "Signs an intent for one payment to one recipient. The intent is the whole record - P2Flux stores nothing and learns nothing later that the token itself does not carry.\n\nThe `reference` is generated here and never accepted from the caller, deliberately: 32 random bytes carry no meaning, so a shop cannot push an order id, a customer id or an email into P2Flux by putting it in the reference. Keep your own `order -> reference` mapping.\n\n**Store the intent.** It is what verifies the payment later, and what recovers it if the callback is lost. An intent whose expiry has passed still verifies and still refunds - see `/v1/payments/verify`. Minimum amount: 0.01 USDC. Smaller amounts are refused as AMOUNT_OUT_OF_BOUNDS before an intent is created - the hosted verification pipeline cannot be run profitably below one cent. The splitter contract itself has no such floor; the minimum is a hosted-service boundary, applied only when an intent is issued.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "recipient": { "$ref": "#/components/schemas/Address" }, "amount": { "$ref": "#/components/schemas/Amount" }, "gas_payment_mode": { "type": "string", "enum": [ "native", "payment_token" ], "default": "native", "description": "How the buyer pays the chain's network fee. Omit for `native`: the buyer sends the transaction and pays gas in the chain's own currency, exactly as every integration written before this field existed. With `payment_token` the buyer needs none of that currency - P2Flux sends the transaction and the buyer reimburses the quoted network cost in the payment token, plus a flat gas-service fee (0.10 USDC). Ask `/v1/capabilities` first: an unsupported network or token is refused here with PAYMENT_TOKEN_GAS_UNSUPPORTED rather than after a customer has been sent to a checkout that cannot work." } }, "required": [ "recipient", "amount" ] } } } }, "responses": { "200": { "description": "The signed intent and everything the checkout needs to build the transaction.", "content": { "application/json": { "schema": { "type": "object", "required": [ "intent", "reference", "amount", "expires_at", "pay" ], "properties": { "intent": { "$ref": "#/components/schemas/Token" }, "reference": { "$ref": "#/components/schemas/Bytes32" }, "amount": { "$ref": "#/components/schemas/Amount" }, "expires_at": { "type": "integer", "description": "Unix seconds. After this the intent cannot START a payment; it can still verify and refund one." }, "pay": { "type": "object", "description": "Nothing secret - what a checkout needs to call the splitter.", "properties": { "chain_id": { "type": "integer" }, "splitter": { "$ref": "#/components/schemas/Address" }, "token": { "$ref": "#/components/schemas/Address" }, "recipient": { "$ref": "#/components/schemas/Address" }, "amount_units": { "$ref": "#/components/schemas/AmountUnits" }, "reference": { "$ref": "#/components/schemas/Bytes32" } } }, "fees": { "type": "object", "description": "What the merchant funds out of the amount, stated rather than implied. The buyer is debited the amount plus the quoted network fee in payment_token mode, and the amount alone natively.", "properties": { "payment_fee_units": { "type": "string", "description": "The percentage fee (1%), to the fee wallet." }, "fixed_network_fee_units": { "type": "string", "description": "The fixed network fee, to the gas treasury. Zero outside payment_token mode." }, "merchant_net_units": { "type": "string", "description": "amount - payment_fee - fixed_network_fee." } } } }, "examples": [ { "intent": "p2f1.k1.eyJ2IjoxfQ.c2lnbmF0dXJl", "reference": "0x4c2958875c223c9880b5b6262b0c069ad6727461c6443475ca2690b872e411ad", "amount": "10.000000", "expires_at": 1787139462, "pay": { "chain_id": 8453, "splitter": "0x5a3bd0945cd0c80b124870881de49a717d20e0d0", "token": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", "recipient": "0xb4e43f3fBa5Add75395adAD366627E7d74141Fa9", "amount_units": "10000000", "reference": "0x4c2958875c223c9880b5b6262b0c069ad6727461c6443475ca2690b872e411ad" } } ] } } } }, "400": { "description": "Refused. `error` names which of: `AMOUNT_OUT_OF_BOUNDS`, `INVALID_REQUEST`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Too many requests from this IP. `retry-after` says when.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "500": { "description": "Refused. `error` names which of: `INTERNAL_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/payments/recover": { "post": { "operationId": "recoverPayment", "summary": "Find a payment whose transaction hash was lost", "tags": [ "One-time payments" ], "description": "For when the checkout window dies between the wallet returning a hash and your server recording it: the money moved, the order looks unpaid, and there is nothing to reconcile against. Give this the intent and it finds the settling transaction on chain.\n\nYou supply no hash and no hint. The match is bound to the exact payment the intent describes, so it can never return somebody else's transaction. Pure reads and idempotent - safe to call on a schedule for any order in doubt, and it works long after the intent expired.\n\n**`found: false` with `PAYMENT_NOT_FOUND` is a statement about one block height, never a permanent verdict.** The contract does not enforce your intent's expiry, so a slow wallet can still settle afterwards and a later call will find it. Stop polling on your own business rules - never on the strength of one not-found.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "intent": { "$ref": "#/components/schemas/Token" } }, "required": [ "intent" ] } } } }, "responses": { "200": { "description": "Either the located transaction, or a not-found that names the block it was true at.", "content": { "application/json": { "schema": { "type": "object", "required": [ "found" ], "properties": { "found": { "type": "boolean" }, "valid": { "type": "boolean" }, "tx_hash": { "$ref": "#/components/schemas/Bytes32" }, "code": { "$ref": "#/components/schemas/ErrorCode" }, "reference": { "$ref": "#/components/schemas/Bytes32" }, "amount": { "$ref": "#/components/schemas/Amount" }, "as_of_block": { "type": "string", "description": "Only on a miss. The head this answer was computed at." }, "settled_outside_intent_contract": { "$ref": "#/components/schemas/Address", "description": "Only on a miss, for a `payment_token` intent whose terms were paid by hand through the original splitter. Not a settlement of this intent - verification holds an intent to the contract it names - but the merchant may hold the money, so the address is named for support." }, "block_number": { "type": "string", "description": "On a found, valid payment: as `/v1/payments/verify` returns it." }, "block_hash": { "$ref": "#/components/schemas/Bytes32", "description": "On a found, valid payment." }, "gas_payment_mode": { "type": "string", "enum": [ "native", "payment_token" ], "description": "On a found, valid payment: how its network fee was paid." }, "accounting": { "$ref": "#/components/schemas/PaymentAccounting", "description": "On a found, valid payment: the same accounting `/v1/payments/verify` returns." }, "settlement_receipt": { "type": "string", "description": "On a found, valid payment: the same sealed receipt `/v1/payments/verify` issues, redeemable there." } } }, "examples": { "recovered": { "summary": "Found and settled", "value": { "found": true, "valid": true, "tx_hash": "0xf9081f1db5f230d3eb481ec68bc8069b33a8409be4f0ea32942aee8d933188c6", "amount": "0.250000" } }, "notFound": { "summary": "Nothing settled AS OF this block - not a permanent answer", "value": { "found": false, "code": "PAYMENT_NOT_FOUND", "as_of_block": "45689099" } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_INTENT`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Limited per IP and per intent - recovery is a background job, not a poll loop.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "`PAYMENT_RECOVERY_INCONSISTENT`: the contract says this settled and a search of every block the contract has existed for cannot find it. Abnormal - retry, and alert.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Refused. `error` names which of: `RECOVERY_UNAVAILABLE`, `RPC_BUSY`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/payments/resolve": { "post": { "operationId": "resolvePayment", "summary": "Authoritative terms for a checkout to display", "tags": [ "One-time payments" ], "description": "What the buyer is about to pay, stated by the server rather than decoded in the browser. A tampered intent fails here, before anyone is shown - or asked to sign - anything.\n\n**This is the only endpoint that enforces expiry.** It also asks the contract whether the payment already settled, so a buyer is never sent to their wallet for a transaction that would revert.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "intent": { "$ref": "#/components/schemas/Token" } }, "required": [ "intent" ] } } } }, "responses": { "200": { "description": "Terms to display and pay against.", "content": { "application/json": { "schema": { "type": "object", "properties": { "recipient": { "$ref": "#/components/schemas/Address" }, "amount": { "$ref": "#/components/schemas/Amount" }, "amount_units": { "$ref": "#/components/schemas/AmountUnits" }, "token": { "$ref": "#/components/schemas/Address" }, "splitter": { "$ref": "#/components/schemas/Address" }, "chain_id": { "type": "integer" }, "reference": { "$ref": "#/components/schemas/Bytes32" }, "expires_at": { "type": "integer" }, "confirmations_required": { "type": [ "integer", "null" ], "description": "How many confirmations the server's finality policy requires before /v1/payments/verify answers valid. Advisory: a client may wait this depth out on its own RPC and then verify once, instead of polling verify while blocks accumulate. Null when the policy is tag-based (safe) - then poll verify as before. The server always applies the policy itself regardless of what the client does with this number." } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_INTENT`, `INTENT_EXPIRED`, `AMOUNT_OUT_OF_BOUNDS`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "This reference has already settled on chain. Nothing is owed.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Refused. `error` names which of: `RPC_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Refused. `error` names which of: `RPC_BUSY`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/payments/verify": { "post": { "operationId": "verifyPayment", "summary": "Verify a payment against the chain", "tags": [ "One-time payments" ], "description": "The trust boundary. The browser saying it paid is a claim; this makes it a fact by re-reading the receipt and checking every field against the signed intent. **Never grant access on a browser message alone.**\n\nA rejected payment is HTTP 200 with `valid: false` and a `code`, not an error status - so check `valid` explicitly.\n\n**`PAYMENT_CONFIRMING` means the customer has probably paid.** The transaction is on chain and not yet settled to the required depth. Poll the same hash; never ask for a second payment.\n\n**Expiry does not apply here.** An intent whose `expires_at` has passed still verifies: expiry stops a payment being started, not one that already happened. An integration that discards expired intents loses the ability to verify - or refund - a real payment, permanently.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "intent": { "$ref": "#/components/schemas/Token" }, "tx_hash": { "$ref": "#/components/schemas/Bytes32" }, "settlement_receipt": { "type": "string", "maxLength": 2048, "description": "Optional. The sealed settlement receipt a previous CONFIRMED verify of this same payment returned. Presenting a valid one answers immediately without re-reading the chain; presenting a missing, expired, malformed or mismatched one silently falls back to the full verification. It is only ever honoured together with the exact intent and tx_hash it was issued for." } }, "required": [ "intent", "tx_hash" ] } } } }, "responses": { "200": { "description": "A verdict. `valid: true` settles the order; `valid: false` carries the reason.", "content": { "application/json": { "schema": { "oneOf": [ { "type": "object", "required": [ "valid", "tx_hash", "reference", "amount", "block_number", "block_hash", "gas_payment_mode", "accounting", "settlement_receipt" ], "properties": { "valid": { "const": true }, "tx_hash": { "$ref": "#/components/schemas/Bytes32" }, "reference": { "$ref": "#/components/schemas/Bytes32" }, "amount": { "$ref": "#/components/schemas/Amount" }, "block_number": { "type": "string" }, "block_hash": { "$ref": "#/components/schemas/Bytes32" }, "gas_payment_mode": { "type": "string", "enum": [ "native", "payment_token" ], "description": "How the network fee of this payment was paid: `native` by the buyer's own ETH, `payment_token` out of the buyer's USDC through a P2Flux-sponsored transaction. Always present on a valid verdict, on both the full verification and the settlement-receipt fast path." }, "accounting": { "$ref": "#/components/schemas/PaymentAccounting", "description": "Every unit of the payment as it settled on chain. Always present on a valid verdict - the settlement-receipt fast path returns the accounting sealed at the original verification, so the two paths answer identically." }, "settlement_receipt": { "type": "string", "description": "Sealed, short-lived proof of this CONFIRMED verdict (token prefix p2paid1). Hand it back on a later verify of the same intent + tx_hash to get the same answer without another chain verification - e.g. from the buyer's browser to the merchant's server. Issued only with valid=true; a confirming or failed verification never carries one." } } }, { "type": "object", "required": [ "valid", "code" ], "properties": { "valid": { "const": false }, "code": { "$ref": "#/components/schemas/ErrorCode" } } } ] }, "examples": { "settled": { "summary": "Paid and settled", "value": { "valid": true, "tx_hash": "0x2d6bbc112885a6976289e599f71003f7d310e7152ebd662c6221dffd3e0da708", "reference": "0x4c2958875c223c9880b5b6262b0c069ad6727461c6443475ca2690b872e411ad", "amount": "10.000000", "block_number": "45688490" } }, "confirming": { "summary": "Paid, still settling - poll the same hash, do not re-charge", "value": { "valid": false, "code": "PAYMENT_CONFIRMING" } } } } }, "headers": { "Retry-After": { "description": "Present on PAYMENT_CONFIRMING answers: seconds to wait before asking again. The server also remembers its last verdict per (intent, tx_hash) for a short window, so re-asking sooner returns the same answer from memory.", "schema": { "type": "string" } } } }, "429": { "description": "Refused. `error` names which of: `RATE_LIMITED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/refunds/prepare": { "post": { "operationId": "prepareRefund", "summary": "Lock the terms of a refund", "tags": [ "Refunds" ], "description": "A refund is a plain USDC transfer **from the merchant's own wallet to the wallet that paid**. There is no refund contract, no relayer and no P2Flux custody in the path: P2Flux charges no refund fee, returns none of its original commission, and the merchant pays the gas.\n\nEverything is derived from the chain. You supply identifiers and an integer amount - there is no field for a recipient anywhere in this API, because a refund endpoint that accepted one would be a withdrawal endpoint.\n\n**P2Flux keeps no refund history.** It cannot tell you whether a payment was already refunded, and calling this twice will happily prepare two valid refunds. One refund per payment is your integration's rule to enforce, and the safe place is BEFORE this call: reserve the order row atomically, then prepare.\n\nThe returned `refund_token` is short-lived and for a browser only. Do not store it - reconciliation later goes through `/v1/refunds/verify` with the original settlement, which needs no token.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "intent": { "$ref": "#/components/schemas/Token" }, "subscription": { "$ref": "#/components/schemas/Token" }, "tx_hash": { "$ref": "#/components/schemas/Bytes32" }, "period_index": { "type": "integer", "minimum": 0, "description": "Recurring only. Refunds are per charge, never per subscription." }, "amount": { "$ref": "#/components/schemas/AmountUnits" } }, "required": [ "tx_hash", "amount" ] } } }, "description": "Identify the ORIGINAL settlement: `intent` (one-time) **or** `subscription` (recurring), plus the `tx_hash` that carried it. The capability says what was authorised, the receipt says what happened. Send exactly one of the two identifiers; sending neither is refused, and sending both prefers `intent`." }, "responses": { "200": { "description": "Terms for the merchant's wallet to send.", "content": { "application/json": { "schema": { "type": "object", "properties": { "refund_token": { "$ref": "#/components/schemas/Token" }, "chain_id": { "type": "integer" }, "token": { "$ref": "#/components/schemas/Address" }, "merchant": { "$ref": "#/components/schemas/Address" }, "payer": { "$ref": "#/components/schemas/Address" }, "original_amount": { "$ref": "#/components/schemas/Amount" }, "original_amount_units": { "$ref": "#/components/schemas/AmountUnits" }, "refund_amount": { "$ref": "#/components/schemas/Amount" }, "refund_amount_units": { "$ref": "#/components/schemas/AmountUnits" }, "expires_at": { "type": "integer", "description": "Unix seconds; about fifteen minutes out." } } }, "examples": { "prepared": { "value": { "refund_token": "p2refund1.k1.eyJ2IjoxfQ.c2lnbmF0dXJl", "chain_id": 8453, "merchant": "0x4e2100539a382e7b91E77D932bE1018243660Be2", "payer": "0x9B710c4Cc6A63Fc0728748Af852e2183fb936262", "original_amount": "0.250000", "original_amount_units": "250000", "refund_amount": "0.250000", "refund_amount_units": "250000" } } } } } }, "400": { "description": "`REFUND_AMOUNT_INVALID`: zero, non-integer, or above the ceiling. The maximum is the COMMERCIAL amount the buyer paid - so a full refund means the merchant absorbs the original P2Flux fee. For a recurring charge the ceiling excludes the gas reimbursement, which paid for a transaction that already happened.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Refused. `error` names which of: `RPC_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/refunds/resolve": { "post": { "operationId": "resolveRefund", "summary": "Read a refund token back (browser)", "tags": [ "Refunds" ], "description": "The terms behind a prepare token, for the browser holding it. Reading is all it does - the token is already signed, so nothing here can change where a refund goes.\n\nConsumed by the hosted checkout, not usually by a server integration: the merchant page must not be able to tell the checkout who the recipient is, or a shop that could name it could redirect a refund.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "refund_token": { "$ref": "#/components/schemas/Token" } }, "required": [ "refund_token" ] } } } }, "responses": { "200": { "description": "Exactly what P2Flux signed.", "content": { "application/json": { "schema": { "type": "object", "properties": { "chain_id": { "type": "integer" }, "token": { "$ref": "#/components/schemas/Address" }, "merchant": { "$ref": "#/components/schemas/Address" }, "payer": { "$ref": "#/components/schemas/Address" }, "amount": { "$ref": "#/components/schemas/Amount" }, "amount_units": { "$ref": "#/components/schemas/AmountUnits" }, "expires_at": { "type": "integer" } } } } } }, "400": { "description": "Both permanent - a malformed or aged-out token never becomes valid. Prepare again.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/refunds/verify": { "post": { "operationId": "verifyRefund", "summary": "Verify a refund transfer against the chain", "tags": [ "Refunds" ], "description": "Did the refund actually happen, and has it settled? Takes the ORIGINAL settlement rather than the prepare token, deliberately: a refund may need reconciling days later - after a crash, or a support ticket - and a fifteen-minute bearer token cannot answer that.\n\nA transaction hash is not a refund. This checks the receipt carries exactly one USDC transfer from the original merchant to the original payer for exactly this amount, matched **by event rather than by transaction sender** - so a Safe or smart account executing on the merchant's behalf verifies correctly.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "intent": { "$ref": "#/components/schemas/Token" }, "subscription": { "$ref": "#/components/schemas/Token" }, "tx_hash": { "$ref": "#/components/schemas/Bytes32" }, "period_index": { "type": "integer", "minimum": 0, "description": "Recurring only. Refunds are per charge, never per subscription." }, "refund_amount": { "$ref": "#/components/schemas/AmountUnits" }, "refund_tx_hash": { "$ref": "#/components/schemas/Bytes32" } }, "required": [ "tx_hash", "refund_amount", "refund_tx_hash" ] } } }, "description": "Identify the ORIGINAL settlement: `intent` (one-time) **or** `subscription` (recurring), plus the `tx_hash` that carried it. The capability says what was authorised, the receipt says what happened. Send exactly one of the two identifiers; sending neither is refused, and sending both prefers `intent`." }, "responses": { "200": { "description": "Settled.", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "const": "REFUNDED" }, "refund_tx_hash": { "$ref": "#/components/schemas/Bytes32" }, "refund_amount": { "$ref": "#/components/schemas/AmountUnits" }, "original_amount": { "$ref": "#/components/schemas/AmountUnits" }, "payer": { "$ref": "#/components/schemas/Address" }, "merchant": { "$ref": "#/components/schemas/Address" }, "block_number": { "type": "string" } } }, "examples": { "settled": { "value": { "status": "REFUNDED", "refund_tx_hash": "0x7ac0b6a532f23a6aa4f0b3aa6dc13665a2a3bdbd17216331a202851b267ccf65", "refund_amount": "250000", "original_amount": "250000" } } } } } }, "400": { "description": "`REFUND_TRANSACTION_MISMATCH`: that receipt does not contain the refund it was supposed to. Never mark an order refunded on this - investigate the transaction.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "`REFUND_CONFIRMING` - the transfer is on chain and not yet settled to the required depth. **The money may already have moved.** Poll the SAME `refund_tx_hash`; sending another refund because this one has not confirmed is how a customer gets paid twice.\n\n*Changed 2026-08-21: this was previously HTTP 400. Branch on the `error` code, not the status.*", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "examples": { "confirming": { "value": { "error": "REFUND_CONFIRMING", "action": "WAIT" } } } } } }, "502": { "description": "Refused. `error` names which of: `RPC_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/subscriptions": { "post": { "operationId": "createSubscription", "summary": "Create subscription terms and a setup token", "tags": [ "Recurring payments" ], "description": "Terms for a recurring authorization the customer signs once. P2Flux has no scheduler and no database: your application owns the subscription lifecycle and decides when a renewal is due; the contract enforces one charge per period against the terms that were signed.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "recipient": { "$ref": "#/components/schemas/Address" }, "amount": { "$ref": "#/components/schemas/Amount" }, "period": { "type": "integer", "description": "Billing period in seconds. Minimum 3600 in production." }, "end": { "type": "integer", "minimum": 0, "description": "Unix seconds, or 0 for no end date." }, "allowance": { "description": "How much ERC-20 allowance the hosted checkout asks the customer's wallet for. 'unlimited' (default: one approval, never asked again), 'until_end' (enough for every period up to `end`; needs an end date), or {periods: N} for N charges' worth (1..1200), after which your restore flow asks again. The mode is carried inside the setup token; a restore session keeps the same mode. Whatever the mode, the allowance only reaches the recurring contract, which moves nothing the signed authorization does not permit.", "oneOf": [ { "type": "string", "enum": [ "unlimited", "until_end" ] }, { "type": "object", "required": [ "periods" ], "additionalProperties": false, "properties": { "periods": { "type": "integer", "minimum": 1, "maximum": 1200 } } } ] } }, "required": [ "recipient", "amount", "period" ] } } } }, "responses": { "200": { "description": "A setup token to hand to the checkout.", "content": { "application/json": { "schema": { "type": "object", "properties": { "setup_token": { "$ref": "#/components/schemas/Token" }, "expires_at": { "type": "integer" }, "chain_id": { "type": "integer" }, "contract": { "$ref": "#/components/schemas/Address" }, "amount": { "$ref": "#/components/schemas/Amount" }, "salt": { "type": "string" } } } } } }, "400": { "description": "A recurring charge carries a fixed network fee, so amounts below roughly 0.102041 USDC cannot leave the merchant anything and are refused.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/subscriptions/finalize": { "post": { "operationId": "finalizeSubscription", "summary": "Exchange a signature for a charge capability", "tags": [ "Recurring payments" ], "description": "Validates the customer's signature against the terms and returns the `p2s2` capability that charges this subscription. Contract wallets are supported (ERC-1271).\n\n**Store the capability.** It is bearer authorization bound to one subscription's signed terms - keep it server-side, encrypted at rest, and never send it to a browser. Losing it means you cannot charge; leaking it means somebody else can, though only ever to the recipient the customer signed for.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "setup_token": { "$ref": "#/components/schemas/Token" }, "payer": { "$ref": "#/components/schemas/Address" }, "signature": { "$ref": "#/components/schemas/Signature" }, "sponsorship": { "type": "object", "additionalProperties": false, "required": [ "quote", "permit_signature", "network_fee_signature" ], "description": "For a customer with no native currency: the two extra signatures from `resolve`. The capability is minted first - proving the authorization costs nothing - and then P2Flux sends one transaction that collects the quoted fee and sets the allowance. Absent for every integration that existed before this did.", "properties": { "quote": { "$ref": "#/components/schemas/Token" }, "permit_signature": { "$ref": "#/components/schemas/Signature" }, "network_fee_signature": { "$ref": "#/components/schemas/Signature" }, "permit_nonce": { "type": "string", "description": "What the wallet was told the token's permit counter was. Checked against the chain before anything is sent." } } } }, "required": [ "setup_token", "payer", "signature" ] } } } }, "responses": { "200": { "description": "The capability and the subscription identity.", "content": { "application/json": { "schema": { "type": "object", "properties": { "subscription": { "$ref": "#/components/schemas/Token" }, "subscription_id": { "$ref": "#/components/schemas/Bytes32" }, "amount": { "$ref": "#/components/schemas/Amount" }, "period": { "type": "integer" }, "end": { "type": "integer" }, "sponsorship": { "type": "object", "description": "Only when `sponsorship` was sent. The subscription exists regardless of this outcome: a sponsorship that fails leaves the capability valid and the allowance to be set from the restore flow or the customer's own wallet. `ALREADY_SETTLED` is a repeat of a request whose allowance was set the first time - confirmed against the chain, because the token reports a spent authorization and one the customer cancelled with the same words, and only one of those means the setup is done. `FAILED` carries the `code` and its details.", "properties": { "status": { "type": "string", "enum": [ "SETTLED", "SPONSORSHIP_CONFIRMING", "ALREADY_SETTLED", "FAILED" ] }, "tx_hash": { "type": "string" }, "network_fee_units": { "type": "string" }, "allowance_units": { "type": "string" }, "block_number": { "type": "string" }, "native_gas_spent_wei": { "type": "string" }, "code": { "$ref": "#/components/schemas/ErrorCode" } } } } } } } }, "400": { "description": "`UNSUPPORTED_SIGNATURE_FORMAT`: an ERC-6492 wrapper - the account must be deployed first, because a recurring authorization is replayed for months. `SIGNATURE_VALIDATION_TOO_EXPENSIVE` is not a verdict that the signature is wrong; it means validating it cost more than we are willing to spend.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "`RATE_LIMITED`: this payer has reached the day's sponsored broadcasts (`SPONSORED_ATTEMPTS_PER_PAYER_24H`). `CONCURRENCY_LIMIT` with `cause: PAYER_UNRESOLVED`: a previous sponsored transaction of this payer is still unresolved on chain; retry after it settles. Only with `sponsorship`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Refused. `error` names which of: `RPC_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "`PAYMENT_TOKEN_GAS_UNAVAILABLE` with `cause: SPONSORSHIP_PAUSED` (the relayer's own infrastructure has been failing and the mode is paused for everyone for up to an hour) or `cause: PAYER_REVERTS` (this wallet's sponsored transactions reverted on chain today and it is refused sponsorship for a day). `RELAYER_NOT_READY`: the relayer cannot record the operation durably. Only with `sponsorship`; the native path is unaffected.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/subscriptions/resolve": { "post": { "operationId": "resolveSubscription", "summary": "Authoritative terms plus the EIP-712 payload to sign", "tags": [ "Recurring payments" ], "description": "What the customer is agreeing to, stated by the server, together with the exact typed data their wallet will show them. `network_fee_estimate` is indicative and best-effort - it may be null if the chain could not be asked, and it is never what gets charged.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "setup_token": { "$ref": "#/components/schemas/Token" }, "gas_payment_mode": { "type": "string", "enum": [ "native", "payment_token" ], "description": "Optional. `payment_token` with `payer` prices the allowance transaction P2Flux would send for a customer holding no native currency, and returns the two messages they sign. Absent means the customer approves from their own wallet, exactly as before." }, "payer": { "$ref": "#/components/schemas/Address", "description": "Required with `gas_payment_mode: payment_token`: the permit is priced for, and signed by, this wallet." } }, "required": [ "setup_token" ] } } } }, "responses": { "200": { "description": "Terms and typed data.", "content": { "application/json": { "schema": { "type": "object", "properties": { "recipient": { "$ref": "#/components/schemas/Address" }, "amount": { "$ref": "#/components/schemas/Amount" }, "amount_units": { "$ref": "#/components/schemas/AmountUnits" }, "period": { "type": "integer" }, "start": { "type": "integer" }, "end": { "type": "integer" }, "token": { "$ref": "#/components/schemas/Address" }, "chain_id": { "type": "integer" }, "contract": { "$ref": "#/components/schemas/Address" }, "salt": { "type": "string" }, "max_gas_reimbursement": { "$ref": "#/components/schemas/AmountUnits" }, "fee_bps": { "type": "integer" }, "network_fee": { "$ref": "#/components/schemas/Amount" }, "network_fee_units": { "$ref": "#/components/schemas/AmountUnits" }, "network_fee_estimate": { "type": [ "string", "null" ] }, "expires_at": { "type": "integer" }, "typed_data": { "type": "object", "description": "EIP-712 domain, types and primaryType." }, "allowance": { "type": "object", "description": "The allowance mode fixed at setup.", "properties": { "mode": { "type": "string", "enum": [ "unlimited", "until_end", "periods" ] }, "periods": { "type": "integer" } }, "required": [ "mode" ] }, "approve_units": { "type": [ "string", "null" ], "description": "What the checkout asks the wallet to approve, in base units. null means unlimited. Never below one charge (amount plus max_gas_reimbursement)." }, "approve_amount": { "type": [ "string", "null" ], "description": "approve_units as a decimal USDC string; null means unlimited." }, "gas_payment_mode": { "type": "string", "enum": [ "payment_token" ], "description": "Present only when the signup was priced for a customer with no native currency." }, "sponsorship_quote": { "type": "object", "description": "With `payment_token`: the quoted network fee, its expiry, and the `p2gas1` quote to hand back to `finalize`." }, "allowance_permit": { "type": "object", "description": "With `payment_token`: the EIP-2612 permit the customer signs. Spender is the recurring contract; value is the subscription's allowance mode." }, "network_fee_authorization": { "type": "object", "description": "With `payment_token`: the token authorization for the quoted fee. Expires with the quote." } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_SETUP_TOKEN`, `SETUP_TOKEN_EXPIRED`, `AMOUNT_OUT_OF_BOUNDS`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/subscriptions/revoke/prepare": { "post": { "operationId": "prepareSubscriptionCancellation", "summary": "Calldata that cancels one subscription", "tags": [ "Cancellation" ], "description": "P2Flux cannot revoke a customer's on-chain authority - only their wallet can. This returns the transaction for them to send. Accepts either the stored capability (server-side) or a cancel token (customer-side).", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "subscription": { "$ref": "#/components/schemas/Token" } }, "required": [ "subscription" ] } } } }, "responses": { "200": { "description": "Unsigned calldata.", "content": { "application/json": { "schema": { "type": "object", "properties": { "chain_id": { "type": "integer" }, "payer": { "$ref": "#/components/schemas/Address" }, "to": { "$ref": "#/components/schemas/Address" }, "data": { "type": "string" }, "subscription_id": { "$ref": "#/components/schemas/Bytes32" }, "description": { "type": "string" } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`, `INVALID_CANCEL_TOKEN`, `CANCEL_TOKEN_EXPIRED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/subscriptions/revoke/session": { "post": { "operationId": "createCancellationSession", "summary": "A short-lived token safe to give a browser", "tags": [ "Cancellation" ], "description": "Exchanges the stored capability for a token that carries the authorization fields needed to build a revoke transaction - and neither the customer's signature nor any ability to charge. Safe to put in a URL fragment; the contract still requires the payer's own wallet to send the transaction, so holding it grants nobody anything.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "subscription": { "$ref": "#/components/schemas/Token" } }, "required": [ "subscription" ] } } } }, "responses": { "200": { "description": "A cancel token.", "content": { "application/json": { "schema": { "type": "object", "properties": { "cancel_token": { "$ref": "#/components/schemas/Token" }, "expires_at": { "type": "integer" }, "subscription_id": { "$ref": "#/components/schemas/Bytes32" }, "payer": { "$ref": "#/components/schemas/Address" } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/subscriptions/status": { "post": { "operationId": "subscriptionStatus", "summary": "Current state, read from the chain", "tags": [ "Recurring payments" ], "description": "Everything about this subscription as the chain sees it. Use it to reconcile after downtime, and to decide whether a renewal is due without guessing from your own clock.\n\nThe signed terms are echoed under `terms` - compare `terms.salt` against the setup you finalized if you need to be certain a capability belongs to the plan you think it does.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "properties": { "subscription": { "$ref": "#/components/schemas/Token" } }, "required": [ "subscription" ] } } } }, "responses": { "200": { "description": "Chain-derived state.", "content": { "application/json": { "schema": { "type": "object", "properties": { "active": { "type": "boolean" }, "revoked": { "type": "boolean" }, "revoked_confirmed": { "type": "boolean", "description": "Revoked deeply enough to act on. `revoked` is read at the head; only this one is settled." }, "expired": { "type": "boolean" }, "due": { "type": "boolean" }, "charged_this_period": { "type": "boolean" }, "subscription_id": { "$ref": "#/components/schemas/Bytes32" }, "period_index": { "type": [ "integer", "null" ] }, "period_start": { "type": [ "string", "null" ] }, "period_end": { "type": [ "string", "null" ] }, "next_period_at": { "type": [ "string", "null" ] }, "allowance_units": { "$ref": "#/components/schemas/AmountUnits" }, "allowance_unlimited": { "type": "boolean" }, "balance_units": { "$ref": "#/components/schemas/AmountUnits" }, "expires_at": { "type": "integer" }, "terms": { "type": "object", "description": "The signed authorization: payer, recipient, token, amount, amount_units, period, start, end, salt." } } } } } }, "400": { "description": "Refused. `error` names which of: `INVALID_SUBSCRIPTION`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "Refused. `error` names which of: `RATE_LIMITED`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "Refused. `error` names which of: `RPC_ERROR`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "503": { "description": "Refused. `error` names which of: `RPC_BUSY`", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/capabilities": { "get": { "operationId": "capabilities", "summary": "What this deployment supports", "tags": [ "One-time payments" ], "description": "Per network, token and operation: whether a buyer can pay without holding the chain's native currency. Read it before offering the option.\n\nArchitectural possibility is not support. A token that implements the right standards on a chain P2Flux has not deployed contracts to and tested is reported `false` here, and every request for it is refused deterministically.\n\nAlso answers POST with an empty body, because an SDK transport may be POST-only.", "responses": { "200": { "description": "Capabilities", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Capabilities" } } } }, "429": { "description": "`RATE_LIMITED`. The answer changes only when the deployment does, so read it at start-up rather than per checkout.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } }, "post": { "operationId": "capabilitiesPost", "summary": "What this deployment supports (POST form)", "tags": [ "One-time payments" ], "description": "Identical to the GET. Exists so a host whose HTTP client only makes POST requests - the WooCommerce plugin injects `wp_remote_post` - can still ask.", "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false } } } }, "responses": { "200": { "description": "Capabilities", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Capabilities" } } } }, "429": { "description": "`RATE_LIMITED`. The answer changes only when the deployment does, so read it at start-up rather than per checkout.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/payments/sponsor": { "post": { "operationId": "sponsorPayment", "summary": "Settle a payment the buyer funded with a signature", "tags": [ "One-time payments" ], "description": "The buyer holds no native currency and has signed the token authorization the checkout showed them. P2Flux sends the transaction and takes the quoted network fee and the gas-service fee out of that same authorization - nothing is fronted on credit, and a transaction that fails moves no money at all.\n\n`status` of `CONFIRMING` means it is in flight. Ask `/v1/payments/verify` about the hash; do NOT call this again, because the buyer's authorization may already be spent.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "required": [ "intent", "quote", "payer", "signature" ], "properties": { "intent": { "type": "string" }, "quote": { "type": "string", "description": "The `p2gas1` token from `/v1/payments/resolve`." }, "payer": { "type": "string", "description": "The wallet that signed. Must match the signature; the contract enforces it." }, "signature": { "type": "string", "description": "65-byte token authorization signature." } } } } } }, "responses": { "200": { "description": "Submitted or confirming", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "enum": [ "SUBMITTED", "CONFIRMING" ] }, "tx_hash": { "type": "string" }, "reference": { "type": "string" }, "network_fee_units": { "type": "string" }, "fixed_network_fee_units": { "type": "string" }, "buyer_total_units": { "type": "string" }, "block_number": { "type": "string" }, "native_gas_spent_wei": { "type": "string", "description": "What the transaction actually cost P2Flux, for reconciliation. The buyer paid the quote, not this." } } } } } }, "400": { "description": "Refused before anything was spent. `error` names which of: `INVALID_INTENT`, `INTENT_EXPIRED`, `INVALID_GAS_QUOTE`, `PAYMENT_TOKEN_GAS_UNSUPPORTED`, `INSUFFICIENT_PAYMENT_TOKEN_FOR_GAS`, `INSUFFICIENT_BALANCE`, `PAYMENT_TOKEN_GAS_UNAVAILABLE` (`cause: SPONSORSHIP_PAUSED` - too many sponsored transactions reverted on chain in the last hour; offer native gas and retry after `retry_after`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "`PAYMENT_ALREADY_PROCESSED`, or `PAYMENT_TOKEN_GAS_QUOTE_EXPIRED` - requote and ask the buyer to sign again. A quote is also refused inside its last 20 seconds, because the token would reject it in the block that includes it.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "`RATE_LIMITED`: this payer has reached the day's broadcasts for this operation (`SPONSORED_ATTEMPTS_PER_PAYER_24H`, default 5). Counted only when a transaction is actually sent; refusals before that are free.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "`SPONSORED_TRANSACTION_FAILED`: broadcast and refused on chain. Nothing moved; retry with a fresh quote.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } }, "/v1/allowances/restore/submit": { "post": { "operationId": "submitAllowanceRestore", "summary": "Carry a signed allowance change onto the chain", "tags": [ "Subscriptions" ], "description": "For a customer who holds no native currency. They signed two things - the allowance change and a bounded fee for the transaction that carries it - and P2Flux sends one call that does both. A change that cannot execute returns the fee, so nobody is charged for something that did not happen.\n\n`allowance_units` of `\"0\"` REMOVES the allowance, which stops collection. That is not a revocation of the recurring authorization: only the payer's own transaction to the recurring contract does that, and the two must be described separately to customers.\n\nNo gas-service fee is added here. A subscription already pays a fixed network fee on every collection.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "additionalProperties": false, "required": [ "approve_token", "quote", "permit_signature", "network_fee_signature" ], "properties": { "approve_token": { "type": "string" }, "quote": { "type": "string" }, "permit_signature": { "type": "string" }, "network_fee_signature": { "type": "string" }, "allowance_units": { "type": "string", "enum": [ "0" ], "description": "Only `\"0\"` is accepted, and it REMOVES the allowance (stops collection - not a revocation of the recurring authorization). Absent means \"restore what this subscription needs\": the allowance is the subscription's own, never a number the browser chooses." }, "permit_nonce": { "type": "string", "description": "What the wallet was told the token's permit counter was. Checked against the chain before anything is sent." } } } } } }, "responses": { "200": { "description": "Settled or confirming", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "enum": [ "SETTLED", "SPONSORSHIP_CONFIRMING" ] }, "tx_hash": { "type": "string" }, "network_fee_units": { "type": "string" }, "allowance_units": { "type": "string" }, "block_number": { "type": "string" }, "native_gas_spent_wei": { "type": "string" } } } } } }, "400": { "description": "`error` names which of: `INVALID_CANCEL_TOKEN`, `CANCEL_TOKEN_EXPIRED`, `INVALID_GAS_QUOTE`, `PAYMENT_TOKEN_GAS_UNSUPPORTED`, `INSUFFICIENT_PAYMENT_TOKEN_FOR_GAS`, `PAYMENT_TOKEN_GAS_UNAVAILABLE` (`cause: SPONSORSHIP_PAUSED` - too many sponsored transactions reverted on chain in the last hour; offer native gas and retry after `retry_after`).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "409": { "description": "`PAYMENT_TOKEN_GAS_QUOTE_EXPIRED`: the price the customer accepted has lapsed; requote and ask them to sign again. A quote is also refused inside its last 20 seconds, because the token would reject it in the block that includes it.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "429": { "description": "`RATE_LIMITED`: this payer has reached the day's broadcasts for this operation (`SPONSORED_ATTEMPTS_PER_PAYER_24H`, default 5). Counted only when a transaction is actually sent; refusals before that are free.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, "502": { "description": "`SPONSORED_PERMIT_FAILED`: broadcast and refused on chain, or the permit nonce moved. Nothing moved; the customer's fee authorization is unspent.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } } }, "security": [], "components": { "schemas": { "Address": { "type": "string", "pattern": "^0x[0-9a-fA-F]{40}$", "description": "An EVM address.", "examples": [ "0xb4e43f3fBa5Add75395adAD366627E7d74141Fa9" ] }, "Bytes32": { "type": "string", "pattern": "^0x[0-9a-f]{64}$", "description": "A 32-byte hex value, lowercase. Transaction hashes and references.", "examples": [ "0x2d6bbc112885a6976289e599f71003f7d310e7152ebd662c6221dffd3e0da708" ] }, "Amount": { "type": "string", "pattern": "^\\d{1,12}(\\.\\d{1,6})?$", "description": "A USDC amount as a decimal string, up to 6 decimal places. Never a JSON number: binary floating point cannot represent most decimal prices exactly, and this is money.", "examples": [ "10.00", "0.250000" ] }, "AmountUnits": { "type": "string", "pattern": "^\\d{1,20}$", "description": "An integer count of micro-USDC (6 decimals), as a string. 2500000 is 2.50 USDC. Used wherever a decimal would invite a rounding error - notably refund amounts.", "examples": [ "2500000" ] }, "Token": { "type": "string", "maxLength": 8192, "description": "A signed P2Flux capability: payment intent (p2f1.), setup token (p2setup2.), subscription capability (p2s2.), cancel token (p2cancel1.) or refund token (p2refund1.). Opaque to the caller and unforgeable - the signature is what authorises the call. Treat it as a bearer secret: keep it server-side, never in a URL query or a log." }, "Signature": { "type": "string", "pattern": "^0x[0-9a-fA-F]+$", "maxLength": 4096, "description": "An EIP-712 signature. Contract wallets (ERC-1271) are supported." }, "Error": { "type": "object", "description": "The uniform error envelope. `error` is the P2Flux code; `action` is what a merchant system should do about it, so integrations never hard-code that table themselves. Extra keys carry detail specific to the code (for example `retry_after`, `confirmations`, `as_of_block`).", "required": [ "error" ], "properties": { "error": { "$ref": "#/components/schemas/ErrorCode" }, "action": { "$ref": "#/components/schemas/MerchantAction" } }, "additionalProperties": true, "examples": [ { "error": "INSUFFICIENT_BALANCE", "action": "CUSTOMER_ACTION_REQUIRED" } ] }, "MerchantAction": { "type": "string", "enum": [ "SUCCESS", "WAIT", "RETRY_LATER", "CUSTOMER_ACTION_REQUIRED", "STOP_SUBSCRIPTION", "INVALID_REQUEST" ], "description": "What to do about a result.\n\n- `SUCCESS` - done, nothing owed.\n- `WAIT` - **the money may already have moved.** The transaction exists and has not settled to the required depth. Ask again about the SAME transaction; never start another one. This is not a failure, and showing the customer an error here tells someone who has paid that they have not.\n- `RETRY_LATER` - nothing happened; the identical call is safe to repeat on your own schedule.\n- `CUSTOMER_ACTION_REQUIRED` - the customer must top up or re-approve.\n- `STOP_SUBSCRIPTION` - terminal; stop charging this subscription.\n- `INVALID_REQUEST` - permanent. Retrying returns the same answer forever; fix the request." }, "ErrorCode": { "type": "string", "enum": [ "INVALID_INTENT", "INTENT_EXPIRED", "INVALID_REFERENCE", "INVALID_SETUP_TOKEN", "SETUP_TOKEN_EXPIRED", "INVALID_CANCEL_TOKEN", "CANCEL_TOKEN_EXPIRED", "TERMS_MISMATCH", "AMOUNT_OUT_OF_BOUNDS", "PERIOD_OUT_OF_BOUNDS", "PERMISSION_NOT_FOUND", "TRANSACTION_NOT_FOUND", "PERMISSION_REVOKED", "ALREADY_CHARGED", "NOT_DUE", "SUBSCRIPTION_EXPIRED", "INVALID_SIGNATURE", "REFUND_CONFIRMING", "INVALID_REFUND_TOKEN", "REFUND_TOKEN_EXPIRED", "REFUND_AMOUNT_INVALID", "REFUND_WRONG_MERCHANT", "REFUND_TRANSACTION_MISMATCH", "REFUND_ORIGINAL_PAYMENT_INVALID", "SIGNATURE_VALIDATION_TOO_EXPENSIVE", "UNSUPPORTED_SIGNATURE_FORMAT", "PAYMENT_ALREADY_PROCESSED", "PAYMENT_NOT_FOUND", "PAYMENT_RECOVERY_INCONSISTENT", "RECOVERY_UNAVAILABLE", "WRONG_SPENDER", "WRONG_TOKEN", "INVALID_EXTRA_DATA", "GAS_FEE_TOO_HIGH", "INSUFFICIENT_ALLOWANCE", "INSUFFICIENT_BALANCE", "INVALID_SUBSCRIPTION", "RPC_ERROR", "RELAYER_ERROR", "INTERNAL_ERROR", "TRANSACTION_REVERTED", "INVALID_REQUEST", "RATE_LIMITED", "CONCURRENCY_LIMIT", "GAS_TOO_HIGH", "GAS_QUOTE_UNAVAILABLE", "PAYMENT_CONFIRMING", "RELAYER_TX_COST_TOO_HIGH", "RELAYER_BUDGET_EXCEEDED", "RELAYER_NOT_READY", "RPC_BUSY", "PAYMENT_TOKEN_GAS_UNSUPPORTED", "PAYMENT_TOKEN_GAS_UNAVAILABLE", "PAYMENT_TOKEN_GAS_QUOTE_EXPIRED", "PAYMENT_TOKEN_GAS_LIMIT_EXCEEDED", "INVALID_GAS_QUOTE", "INSUFFICIENT_PAYMENT_TOKEN_FOR_GAS", "SPONSORED_TRANSACTION_FAILED", "SPONSORED_PERMIT_FAILED", "SPONSORSHIP_CONFIRMING" ], "description": "Every code this API can return. Stable identifiers - branch on these, never on the human-readable text or the HTTP status alone." }, "NetworkFeeQuote": { "type": "object", "description": "A price for one sponsored operation, and how long it stands. `quoted_network_fee_units` is what the buyer accepts and pays - a price agreed before execution, never a measurement of the gas the transaction turns out to use. Requote when it expires; a stale quote cannot be executed by anyone, including P2Flux.", "properties": { "quoted_network_fee": { "type": "string", "example": "0.004000" }, "quoted_network_fee_units": { "type": "string", "example": "4000" }, "max_network_fee_units": { "type": "string", "description": "The ceiling this price may not exceed." }, "fixed_network_fee": { "type": "string", "example": "0.100000", "description": "The fixed network fee on a one-time payment. MERCHANT-funded: it comes out of the amount, like the percentage fee, and is paid to the gas treasury - the same arrangement a subscription has. It is never added to what the buyer is debited." }, "fixed_network_fee_units": { "type": "string", "description": "The fixed network fee on a one-time payment. MERCHANT-funded: it comes out of the amount, like the percentage fee, and is paid to the gas treasury - the same arrangement a subscription has. It is never added to what the buyer is debited." }, "buyer_total": { "type": "string" }, "buyer_total_units": { "type": "string" }, "quoted_at": { "type": "integer" }, "expires_at": { "type": "integer" }, "quote": { "type": "string", "description": "Opaque `p2gas1` token. Hand it back unchanged; it is bound to this payment and cannot be spent on another." } } }, "PaymentAccounting": { "type": "object", "description": "Every unit of a settled payment, read from the chain rather than recomputed from what someone said they would charge.", "properties": { "payment_units": { "type": "string" }, "payment_fee_units": { "type": "string", "description": "The P2Flux percentage fee, taken out of the amount. The merchant's net is smaller by this." }, "network_fee_units": { "type": "string", "description": "The quoted network fee the buyer accepted. Zero in native mode." }, "fixed_network_fee_units": { "type": "string", "description": "The fixed network fee on a one-time payment. MERCHANT-funded: it comes out of the amount, like the percentage fee, and is paid to the gas treasury - the same arrangement a subscription has. It is never added to what the buyer is debited." }, "merchant_net_units": { "type": "string" }, "buyer_total_units": { "type": "string" }, "payer": { "type": "string" } } }, "Capabilities": { "type": "object", "properties": { "chain_id": { "type": "integer" }, "network": { "type": "string" }, "native_currency": { "type": "string" }, "supported": { "type": "boolean" }, "tokens": { "type": "array", "items": { "type": "object", "properties": { "address": { "type": "string" }, "symbol": { "type": "string" }, "decimals": { "type": "integer" }, "gas_payment_modes": { "type": "array", "items": { "type": "string", "enum": [ "native", "payment_token" ] } }, "fixed_network_fee_units": { "type": "string" }, "operations": { "type": "object", "additionalProperties": { "type": "boolean" }, "description": "one_time_payment, subscription_signup, allowance_restore, allowance_removal." }, "sponsor_contracts": { "type": "object", "description": "The contract that carries each sponsored operation, or null where the operation is not offered. A checkout compares the fee recipient of any sponsorship offer against this before asking the buyer to sign.", "additionalProperties": { "oneOf": [ { "$ref": "#/components/schemas/Address" }, { "type": "null" } ] } }, "zero_native_revoke": { "type": "boolean", "description": "Always false: revoking a recurring authorization is the payer's own transaction, by the contract's design. Removing an allowance stops collection and is a different act." } } } } } } } } }