Docs
PayLoop subscriptions
Non-custodial USDC subscriptions on Soroban, and how a merchant accepts them.
PayLoop is a Soroban contract for recurring USDC payments. A subscriber signs one policy: which merchant to pay, how much, how often, and a safety cap on the total. Funds stay in the subscriber's wallet until a charge is due. A relayer submits each charge on schedule, and the subscriber can pause, resume, or revoke from their own wallet at any time.
Testnet and Mainnet
PayLoop runs on Stellar Testnet (test USDC) and Stellar Mainnet (real
Circle USDC), each with its own contract and relayer. Pick the network with
the switch on the PayLoop page, or ?network=mainnet on every
API call. The mainnet contract has had an internal security review but no
independent audit yet; GET /api/payloop/config?network=mainnet reports
audited, and the page shows a notice while no audit is recorded.
| Network | Contract |
|---|---|
| Testnet | CCNMZWP2NQMTJF2J42W4DNYNOAQQYWOCJ2IQROXWYSJVOI3DKSH7YRIH |
| Mainnet | CDQ6I65CP3JHLNGMEHWE4BP2OMLWOUMZ77U53JPAURFZPZBVH6PYEXPN |
Both run the same build (wasm d42be154…a19318). The contract has no admin,
no pause switch, and no upgrade path.
How a policy works#
| Term | Enforced on-chain |
|---|---|
| Merchant | Charges can only pay this address. |
| Amount | Every charge is exactly this amount of USDC. |
| Interval | A charge is accepted only once it is due (one minute to 90 days). Consecutive charges are always nearly a full interval apart. |
| Safety cap | Total charges never exceed it. The policy completes when less than one charge remains. |
Atomic first charge. create_policy grants the allowance and takes the
first charge in one invocation under one signature. If the first charge fails,
for example because the wallet lacks USDC, the whole call reverts: no policy,
no allowance.
Non-custodial. The contract never holds funds. It holds a USDC allowance
covering what the subscriber's live policies may still charge. Every charge
consumes exactly one amount of it. Allowance left over from an ended policy
cannot be spent, because every policy is capped, and Renew allowance
(refresh_allowance) trims it.
Permissionless charging. Anyone may call charge(policy_id). That is safe
because the policy fixes the recipient, amount, schedule, and cap. A charge the
subscriber cannot cover is recorded on-chain (ChargeFailed, at most once a
day) instead of failing silently. Missed periods are never back-billed: a late
charge starts a fresh period, so it is never followed by another charge
moments later.
| Action | Who signs | Effect |
|---|---|---|
create_policy | Subscriber | Validates terms, grants the allowance, takes the first charge. |
charge | Anyone (the relayer) | Takes a due charge. |
pause / resume | Subscriber | Stops and restarts charges. Resuming after a missed date starts a new period now. |
revoke | Subscriber | Ends the policy. Final. It never fails because of activity on the subscriber's other policies. |
cancel | Merchant | Ends the policy from the merchant side. Final. |
refresh_allowance | Subscriber | Re-grants exactly the committed allowance with a fresh expiry (renew or trim). |
Allowances are granted for about 100 days. Long-running subscriptions call
refresh_allowance before then; until they do, the relayer reports the charge
as insufficient_allowance.
Try it#
Open PayLoop on testnet. Connect a wallet, fund it with test USDC in one click, then sign a one-minute policy. Within a minute or two you will see the relayer's charges arrive, each with its Stellar Expert receipt. Pause, resume, or revoke from the same page.
On mainnet, switch the page to Mainnet and set your wallet to Mainnet. The wallet needs a Circle USDC trustline and enough USDC for the first charge, which settles immediately. Start with a small amount and a low cap; you can revoke at any time.
Integrate with the SDK#
npm install @web-soroban/payloop @stellar/stellar-sdkimport { createPayLoopClient } from '@web-soroban/payloop'
const payloop = createPayLoopClient({ network: 'mainnet', rpcUrl: process.env.MAINNET_RPC_URL })
// Browser: the customer subscribes from their own wallet.
await payloop.send(
() => payloop.buildCreatePolicy({ subscriber, merchant, amount: '9.99', intervalSeconds: 30 * 86_400, cap: '119.88' }),
(xdr) => signWithWallet(xdr),
)
// Server: gate access on an active policy.
const plan = await payloop.activeSubscription({ subscriber, merchant, amount: '9.99', intervalSeconds: 30 * 86_400 })The SDK builds unsigned transactions for any Stellar wallet (buildPause,
buildResume, buildRevoke, buildCancel, buildRefreshAllowance), retries
only failures the network confirmed, reads policies from the contract or the
index, and verifies SEP-53 wallet signatures (verifyWalletSignature). There is
no public SDF RPC on mainnet, so pass your provider's rpcUrl.
Accept PayLoop as a merchant#
A merchant does not run any chain infrastructure. The WebSoroban relayer submits charges, and the indexer exposes policies and payments.
1. Read a subscriber's policies#
GET /api/payloop/policies?merchant=G…YOUR_MERCHANT&status=active&network=mainnet
GET /api/payloop/policies?subscriber=G…CUSTOMER&network=mainnet
GET /api/payloop/policies/:policyId?network=mainnet # policy + its charge historynetwork is testnet (the default) or mainnet. Policy ids are per network.
A network without a deployed contract returns 404.
Treat a customer as subscribed when they have an active policy to your
merchant address whose amount and intervalSeconds match your plan.
2. Prove the customer owns the wallet#
A wallet address in a request proves nothing on its own. Ask the customer's
wallet to sign a short message (SEP-53 signMessage, supported by Freighter),
then verify it server-side before granting access:
import { verifyWalletSignature } from '@web-soroban/payloop'
if (!verifyWalletSignature(address, message, signatureBase64)) throw new Error('Not your wallet')Include a timestamp or nonce in the message and reject stale ones.
3. React to charges#
Each policy's charges count and nextChargeAt update as the relayer
charges. Poll /api/payloop/policies?merchant=… and compare charges with
what you last saw. Failed attempts appear as charge rows with outcome
insufficient_balance or insufficient_allowance, and the relayer retries
them with backoff.
The PayLoop merchant template in the IDE implements all three steps as a runnable Express app.
Reliability#
GET /api/payloop/statsreports policies by status, charge outcomes, and how many recurring charges landed within a minute of their due time (p50 and p95 lateness).- A reconciliation job compares every policy and its charge history in the index against the contract and exits non-zero on any difference.
- Only one relayer instance submits charges at a time (a database lease), so extra replicas add availability without double-submitting.
