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.

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.

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.

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.
CONFIRMINGWAIT — broadcast, not yet settled. The period stays open; ask again with nothing changed.
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.

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.

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

Not currently available. Renewals settle directly to your wallet, so returning value is an ordinary transfer from the wallet that received it, made by you.

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