Every API here is available over the APIs.io API and to AI agents over MCP.
{
"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 `<checkout>/#/approve/<approve_token>`.\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
# --- truncated at 32 KB (118 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/eventedge-oracle/refs/heads/main/openapi/eventedge-oracle-openapi.json