PFlux DOCS
GitHub Integration enquiries Start integrating
SUBSCRIPTIONS/RECURRING PAYMENTS

Recurring payments

The customer signs an authorization once. Your system decides when a renewal is due and asks P2Flux to execute it; the contract enforces one charge per period.

AUTHORIZATIONEIP-712, signed once
SCHEDULERYours
FEE2% + 0.10 USDC
MIN PERIOD1 hour
YOUR APPLICATION OWNS THE LIFECYCLE

P2Flux has no scheduler and no database. It does not know when a renewal is due, who the customer is, or what happens after a failure. It executes one charge when you ask, and the contract enforces one charge per period.

How it works

The customer signs an EIP-712 authorization once, with an ordinary EVM wallet, naming exactly what may be taken: payer, recipient, token, amount, period, start, end and a salt. The contract enforces those terms. The amount cannot be raised, the period cannot be shortened, and the recipient cannot be changed after signing. A charge outside the signed window, or a second charge inside one period, reverts.

The customer also grants an ordinary ERC-20 allowance to the recurring contract — that is the switch they can flip to stop everything, from their own wallet, without asking anyone. Unlimited by default, because a subscription with no end and a bounded allowance is one that stops one day; you can bound it at setup with allowance, and the resolve response then states the exact number the checkout asks the wallet to approve (approve_units).

1. Create the terms

POST/v1/subscriptions

Technical terms in, setup token out. No customer or product fields exist here.

FIELDTYPEREQUIREDNOTES
recipientstringYesAddress that receives every renewal.
amountstringYesDecimal USDC charged per period, before the fee split.
periodintegerYesSeconds. Minimum 3600, maximum 366 days by default.
endintegerNoUnix seconds. 0 or omitted means no end date.
allowancestring | objectNoHow much USDC allowance the checkout asks the wallet for: "unlimited" (default — one approval, never asked again), "until_end" (enough for every period up to end), or {"periods": N} for N charges’ worth, after which your restore flow asks again. Carried inside the setup token; the restore session keeps the same mode. The allowance only reaches the recurring contract, which moves nothing the signed authorization does not permit — the mode bounds the blast radius of a fault, at the cost of a wallet prompt when it runs out.

The response returns the setup_token, its expires_at (24 hours by default), the chain_id, the recurring contract, the formatted amount, and a salt.

SALT

Store the salt with the pending order. It is what distinguishes two setups whose price and period are identical — so when a capability comes back, you can prove it belongs to this order and not to someone else’s cheaper plan.

2. Customer authorizes

Send the customer to <checkout>/#/subscribe/<setup_token>. The checkout resolves the token server-side, shows the terms, takes the token approval if one is needed, and collects the EIP-712 signature.

Those are two different things. The approval is an on-chain transaction from the customer’s wallet, so it costs network gas in ETH; it is the only point in the subscription where they need native currency. The signature is not a transaction — it is signed in the wallet, costs nothing and touches no chain.

The signature is exchanged for a capability — the p2s2 token — which is what your system stores and charges against later. The checkout posts it to the opening page as p2flux.subscription.created with the capability in subscription.

VERIFY BEFORE ACTIVATING

Call status and compare the echoed terms — payer, recipient, token, amount, period, start, end, salt — against what you actually sold. A capability can be cryptographically valid and still be the wrong one.

If you build your own page instead of using the hosted checkout, the same two calls are public: /v1/subscriptions/resolve returns the terms and the EIP-712 scaffold, and /v1/subscriptions/finalize exchanges setup_token, payer and signature for the capability.

Answer the checkout: the first charge verdict

After p2flux.subscription.created your page attempts the first charge server-side. The popup is still open, telling the customer the seller is collecting - and it stays honest only if you answer it. Two messages, both sent with postMessage to the checkout origin:

MESSAGEDIRECTIONPAYLOAD
p2flux.subscription.createdcheckout → your pagesubscription — the capability. Store it; nothing else ever carries it.
p2flux.finalizedyour page → checkoutOptional tx_hash. The charge succeeded and you recorded it; the checkout shows the receipt (and the explorer link when the hash is present).
p2flux.activation_failedyour page → checkoutcode — a bare identifier. The charge failed in a way your renewal job will NOT quietly recover; the checkout shows its own wording for it.

Send p2flux.activation_failed only for failures the customer or you must act on — the charge() result’s action already makes the split: CUSTOMER_ACTION_REQUIRED (INSUFFICIENT_BALANCE, INSUFFICIENT_ALLOWANCE) and STOP_SUBSCRIPTION (PERMISSION_REVOKED, SUBSCRIPTION_EXPIRED) are worth reporting, plus your own configuration refusals. For RETRY_LATER and WAIT send nothing: your renewal job finishes the activation, and the checkout keeps saying so.

THE CHECKOUT OWNS THE WORDING

Send the CODE, never a sentence. The checkout composes what the customer reads from a fixed set of identifiers and degrades unknown ones to a safe generic - a merchant page can name a failure, but it cannot write on pay.p2flux.com. Codes it distinguishes: INSUFFICIENT_BALANCE, INSUFFICIENT_ALLOWANCE, PERMISSION_REVOKED, SUBSCRIPTION_EXPIRED, and your configuration refusals as a seller-issue message.

IF THE HANDOFF IS LOST

If your page dies after the handshake and the capability never reaches you, nothing chargeable is orphaned - nobody holds it. Send the customer back to the same subscribe link: every term the on-chain id derives from, start and salt included, is fixed in the setup token, so signing it again reproduces the same subscription rather than a second one. While it waits, the checkout offers the customer Copy subscription ID - the public on-chain id, a support reference that can charge nothing. It never offers the capability.

3. Charge each period

POST/v1/charges

Attempt this period’s payment. Safe to retry.

Call it from your existing renewal job. There is no amount and no recipient in the request — both come from the permission the customer signed.

import { createP2Flux } from '@p2flux/sdk'

const p2flux = createP2Flux({ apiUrl: process.env.P2FLUX_API_URL })

const result = await p2flux.charge(subscriptionRef)

if (result.ok) markRenewalPaid()                // CHARGED or ALREADY_CHARGED
else if (result.action === 'STOP_SUBSCRIPTION') cancelLocally()
else if (result.action === 'CUSTOMER_ACTION_REQUIRED') emailCustomer()
else if (result.retryable) scheduleRetry()      // your schedule, not ours
curl -X POST "$P2FLUX_API_URL/v1/charges" \
  -H "content-type: application/json" \
  -d '{ "subscription": "p2s2.k1.…" }'

# 200
{ "status": "CHARGED", "ok": true, "action": "SUCCESS",
  "tx_hash": "0x…", "period_index": 3,
  "next_period_at": "2026-10-01T00:00:00.000Z" }

P2Flux submits the transaction and pays its ETH gas up front. That cost is converted to USDC and added on top of the amount, so the contract debits amount + gas reimbursement — the customer reimburses it in the stablecoin they already hold and needs no ETH at renewal time. Two ceilings apply and the lower wins: what the customer signed for, and a hard 0.05 USDC cap in the contract.

The two P2Flux fees are separate money and come out of the amount instead, so the merchant funds them. Full arithmetic in Fees & gas.

Charge results

A charge answers with a status and an action. The action is what your system should do about it, so you never have to hard-code the code table yourself.

CHARGEDSUCCESS — the money moved. tx_hash is present.
ALREADY_CHARGEDSUCCESS — this period was already collected. The normal result of a retry after a timeout; no transaction is sent and no hash is returned. The period is paid; to attribute, audit or refund it you need the settlement, which recoverCharge finds.
CONFIRMINGWAIT — broadcast, not yet settled. Not final settlement. The period stays open; ask again with nothing changed, and never send a second charge.
NOT_DUERETRY_LATER — the period has not opened yet. next_period_at says when.
INSUFFICIENT_BALANCECUSTOMER_ACTION_REQUIRED — the customer’s wallet is short.
INSUFFICIENT_ALLOWANCECUSTOMER_ACTION_REQUIRED — the allowance was removed or never granted.
PERMISSION_REVOKEDSTOP_SUBSCRIPTION — revoked on chain. Permanent.
SUBSCRIPTION_EXPIREDSTOP_SUBSCRIPTION — past the authorization’s end date.
INVALID_SUBSCRIPTIONINVALID_REQUEST — malformed, forged, or for another deployment.
GAS_TOO_HIGHRETRY_LATER — the network cost quoted above what the customer authorized, or above the 0.05 USDC cap. Nothing was broadcast and nothing was spent; the period stays open.
RETRIES ARE SAFE

The contract allows one charge per billing period, so repeating a call after a timeout or a crash returns ALREADY_CHARGED rather than charging twice. There is no idempotency key to manage — the period is the key.

The retry schedule is yours. P2Flux reports the technical result; how long you wait, and whether you dun the customer, is business policy.

Recover a lost charge

POST/v1/charges/recover

The transaction that charged one exact period.

ALREADY_CHARGED proves a period was collected and names no transaction: P2Flux stores nothing, so the hash lives only in the contract’s log. Without it a paid period cannot be attributed to an order, audited, or refunded - both refund calls start from the original settlement. This finds it.

FIELDTYPEREQUIREDNOTES
subscriptionstringYesThe capability.
period_indexintegerYesThe exact period, from the charge result or from status. There is no "current period" form: you are reconciling one specific collection, today or in a year, and the answer must not move under you.
hintobjectNo{ attempted_at } (unix seconds) or { block }: where your own records say you attempted the charge. Narrows the search and nothing else.
THE EVENT IS THE PROOF

A settlement is returned only when the contract’s own SubscriptionCharged log names this subscription AND this period, and its payer, recipient and amount match the signed authorization. The contract’s period marker is a gate, never evidence: it is monotonic, so a marker of 7 says period 6 was collected and says nothing about period 5. Skipped periods are ordinary - there is no catch-up billing - and a skipped period answers PAYMENT_NOT_FOUND, never a later period’s transaction. A hint can never turn a miss into a hit.

found: truetx_hash, block_number, subscription_id, period_index, payer, recipient, net_units, fee_units, network_fee_units and amount_units (their sum: the signed amount). Check the id, the period, the recipient and the amount against what you expected before you act.
PAYMENT_NOT_FOUNDNo settlement for this period as of as_of_block. Ordinary for a skipped period; a statement about one block, never a permanent verdict. HTTP 200.
PAYMENT_CONFIRMINGA settlement exists and is not deep enough to act on. tx_hash rides along; ask again about that same one. HTTP 409.
RECOVERY_UNAVAILABLEThe search could not be completed within its bounded budget on this deployment. Retry later. HTTP 503.
PAYMENT_RECOVERY_INCONSISTENTA log exists and contradicts the signed terms. Rare and abnormal - never treat it as a payment. HTTP 502.

Long periods. A charge can land anywhere inside its period, and a period can be 366 days - about 15.8 million blocks on Base, far more than one request may scan. The API bisects the contract’s marker over historical state and reads one log range at the crossing block, so a yearly period costs about the same as an hourly one. On an RPC provider without historical state it falls back to scanning the period window under a bounded budget, and when even that cannot finish it answers RECOVERY_UNAVAILABLE - never a wrong found: true.

Reconcile with status

POST/v1/subscriptions/status

Current state, read straight from the chain.

No stored state is consulted, because there is none. Use it to reconcile after downtime and to check the signed terms before activating anything.

FIELDMEANING
activeNot revoked and not expired.
dueStarted, not expired, not revoked, and this period has not been charged.
charged_this_periodWhether the current period has already been collected.
period_index, period_start, period_endWhere the subscription is in its own schedule.
next_period_atThe earliest the next charge can succeed. Null once revoked or expired.
revoked / revoked_confirmedThe contract refuses charges as soon as revoked is set; revoked_confirmed waits for confirmation depth. Close accounts on the confirmed one.
allowance_units, allowance_unlimited, balance_unitsThe customer’s current ERC-20 allowance and USDC balance.
termsThe signed authorization, echoed back. Compare it against what you sold.

Restore an allowance

POST/v1/allowances/restore/session

A narrow session for repairing one subscription’s allowance.

INSUFFICIENT_ALLOWANCE is not a dead subscription. The authorization the customer signed is intact and you can still collect; what ran short is the ERC-20 allowance to the recurring contract, and the fix is one approve() from the customer’s own wallet - no new signature, no new subscription.

Exchange the capability for an approve_token and open <checkout>/#/approve/<approve_token>. The checkout connects the payer’s wallet (and refuses any other), reads the allowance, asks for exactly one approval against the server-named spender, and posts p2flux.allowance.restored with the tx_hash - or already_sufficient: true when nothing needed doing. Then charge the same capability again.

A customer whose wallet holds no ETH cannot send that approval — and the customer whose subscription needs repairing is exactly the person least likely to go and buy some first. The same screen handles it: it reads their native balance, and where there is none it asks P2Flux to price the transaction, shows that price, and takes two signatures instead. P2Flux sends one call that collects the quoted fee and sets the allowance together — either both happen or neither does — and the customer pays no additional fee for it, because a subscription already pays its fixed network fee on every collection. Nothing changes on your side: you post the same session and charge the same capability afterwards.

P2APPROVE1 IS THE NARROWEST TOKEN P2FLUX ISSUES

It names the payer, the spender, the token and how much the next charge pulls, and carries no authorization struct and no signature. It cannot charge a subscription, cannot revoke an authorization, cannot prepare a refund, and cannot become a p2s2 capability. It lives fifteen minutes. /v1/allowances/restore/resolve is the browser-side read the checkout uses to show the terms.

Cancel and revoke

Cancellation belongs to the customer. P2Flux cannot revoke a wallet’s authority and does not pretend to — the API returns unsigned calldata, and the customer’s own wallet sends it.

CALLWHAT YOU GET
/v1/subscriptions/revoke/prepareCalldata for revoke() on the recurring contract. Stops this one subscription.
/v1/allowances/revoke/prepareCalldata for approve(contract, 0). Stops every P2Flux subscription paid in this token from that wallet.
/v1/subscriptions/revoke/sessionA short-lived cancel token (15 minutes) that is safe to put in a browser.

The cancel token exists so the capability never has to reach a browser: it carries the fields needed to build revoke() and not the customer’s signature, so it cannot be charged with. Open <checkout>/#/cancel/<cancel_token> to give customers self-service cancellation.

Failed renewals

Nothing retries on its own. A failed charge spent no money and changed nothing on chain, so the correct response is entirely yours to choose — the action field tells you which kind of failure it was.

RETRY_LATERInfrastructure or timing: rate limits, gas conditions, RPC trouble, relayer capacity. Try the identical call later.
CUSTOMER_ACTION_REQUIREDBalance or allowance. Only the customer can fix it; charging again before they do will fail the same way.
STOP_SUBSCRIPTIONRevoked or expired. Stop billing and close the subscription locally.
INVALID_REQUESTThe request itself is wrong — a bad capability, or terms the service will not accept. Retrying repeats the answer forever.

Refunds

Supported, per charge rather than per subscription. You identify the renewal with the capability, the transaction that charged it and its period_index, and P2Flux derives who to pay back and how much from the settlement itself.

Refunding a renewal does not cancel the subscription: it stays active and the next renewal is still due. Stopping future charges is the customer’s action, described above. Full detail in Refunds.

Something more than a standard integration?
Marketplace flows, platform billing and custom settlement logic.
Discuss an integration