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.

NetworkContract
TestnetCCNMZWP2NQMTJF2J42W4DNYNOAQQYWOCJ2IQROXWYSJVOI3DKSH7YRIH
MainnetCDQ6I65CP3JHLNGMEHWE4BP2OMLWOUMZ77U53JPAURFZPZBVH6PYEXPN

Both run the same build (wasm d42be154…a19318). The contract has no admin, no pause switch, and no upgrade path.

How a policy works#

TermEnforced on-chain
MerchantCharges can only pay this address.
AmountEvery charge is exactly this amount of USDC.
IntervalA charge is accepted only once it is due (one minute to 90 days). Consecutive charges are always nearly a full interval apart.
Safety capTotal 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.

ActionWho signsEffect
create_policySubscriberValidates terms, grants the allowance, takes the first charge.
chargeAnyone (the relayer)Takes a due charge.
pause / resumeSubscriberStops and restarts charges. Resuming after a missed date starts a new period now.
revokeSubscriberEnds the policy. Final. It never fails because of activity on the subscriber's other policies.
cancelMerchantEnds the policy from the merchant side. Final.
refresh_allowanceSubscriberRe-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#

code
npm install @web-soroban/payloop @stellar/stellar-sdk
code
import { 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#

code
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 history

network 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:

code
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/stats reports 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.