Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: P2Flux Recurring payments API
version: 1.0.0
summary: Non-custodial USDC payments, subscriptions and refunds on Base.
description: Programmable, non-custodial payments on Base.
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.
security: []
tags:
- name: Recurring Payments
description: Signed once by the customer, charged by your renewal job.
paths:
/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.
The 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.
This 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/`.
Short-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/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.
**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.
**`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.
Every 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.
Give 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.
**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.
**`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.
A `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/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).
**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/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.
The 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'
# --- truncated at 32 KB (38 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/eventedge-oracle/refs/heads/main/openapi/eventedge-oracle-recurring-payments-api-openapi.yml