Docs · Arc testnet
Accept recurring USDC on Arc
Everything a merchant needs to bill subscriptions with Levy: plans, checkout links, webhooks and how to verify them, invoices, and what happens when a payment fails.
Status. Levy runs on Arc testnet. Payments use test USDC and no real money moves. The contracts are not audited and not on mainnet yet; addresses below are the testnet ones.
1. Quickstart
From nothing to a working checkout link on Arc testnet, all in the browser:
- A wallet. Use a browser wallet such as MetaMask. The app asks it to add Arc Testnet the first time it sends a transaction.
- Test USDC. Get some at faucet.circle.com (choose Arc Testnet). On Arc, USDC also pays the network fee, so you need no other token.
- Sign in. Open app.getlevy.xyz and sign in with that wallet. Signing is free and sends no transaction. This wallet is the one your payments go to.
- Create a plan. Under Plans, choose New plan: a price, a billing cycle, an optional free trial and how many failed payments to allow before the subscription is past due. Creating it is one transaction from your wallet.
- Share the checkout link. Copy the link next to your plan and send it to customers. Add
&ref=and your own customer id to tie each subscription to a customer (§4). - Get notified. Under Webhooks, add your endpoint and choose events, then check every delivery's signature as in §6.
- Watch it run. Subscriptions, invoices and the dunning queue appear in the dashboard. Your customers manage their subscriptions at app.getlevy.xyz/portal.html.
2. Network and contracts
| Item | Value |
|---|---|
| Network | Arc Testnet, chain id 5042002 |
| RPC | https://rpc.testnet.arc.io |
| Explorer | explorer.testnet.arc.io |
| SubscriptionManager | 0x62a6512335d630ef19fcadb497adf98d883f77c6 |
| FeeRouter | 0x4819975f4fa3a693ec67539bcb121eb06f280541 |
| USDC | 0x3600000000000000000000000000000000000000 (6 decimals through the ERC-20 interface) |
| Permit2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
| API | https://app.getlevy.xyz |
All USDC amounts in the contract and the API are in 6-decimal units: 1 USDC = 1,000,000.
3. Plans
A plan is a price and a billing cycle, stored on-chain under your address. The simplest way to create one is New plan in the dashboard, which checks the limits below and shows what customers will pay. Underneath, it calls:
createPlan(
price, // uint256, per-cycle price in 6-decimal USDC units
cycleSeconds, // uint256, billing cycle length in seconds
trialSeconds, // uint256, trial length in seconds (0 for none)
graceSeconds, // uint256, informational only (the retry schedule lives in the keeper)
maxFailed // uint8, failed payments before the subscription goes PAST_DUE
)
The contract requires a price above 0, a cycle from 1 day (86400) to 10 years, a trial shorter than the cycle, and maxFailed from 1 to 255. Common cycles: weekly 604800, monthly 2592000 (30 days), quarterly 7776000 (90 days), yearly 31536000 (365 days). The call emits PlanCreated with your planId; GET /api/plans lists every plan.
- price is what the customer pays each cycle. It is fixed: to change a price, create a new plan and send new customers to it.
- cycleSeconds is how often a payment is collected. "Monthly" is a fixed 30 days, so billing dates drift against calendar months.
- trialSeconds delays the first charge. With 0, the first cycle is charged the moment the customer subscribes.
- maxFailed is how many failed payments a subscription tolerates before it is past due. At twice this number it pauses itself. 2 or 3 fits most businesses.
4. Checkout links and references
Send customers your plan's checkout link:
https://app.getlevy.xyz/checkout.html?plan=<planId>&ref=<reference>
It shows the plan, connects the customer's wallet, explains the allowance and takes two wallet confirmations: an approval for the number of cycles they choose (12 by default, added to any Levy allowance they already have), then the subscription. Without a trial, the first cycle is charged right away. The page ends on a receipt.
ref is how you match a subscription to your own customer. It is stored with the subscription on-chain and sent on every webhook (null when none was given). It can be 0x plus 64 hex digits, or up to 31 plain ASCII characters. Two rules:
- It is public. Anyone can read it on-chain, so never use an email, a name or anything personal. Use an opaque id.
- Whoever subscribes chooses it. Someone could subscribe with another customer's reference and then cancel, making that customer look cancelled. Issue an unguessable reference per checkout (for example 32 random bytes you store against the customer), and once
subscription.createdlinks a reference to a customer, track that subscription by itssubId.
Building your own flow
Direct approval, two transactions. The customer calls approve on USDC for the SubscriptionManager, for at least one cycle's price, then subscribe(planId, ref) on the SubscriptionManager. Without a trial this charges the first cycle at once, and fails if it cannot.
Permit2, one transaction after a one-time setup. The customer has approved Permit2 for USDC once (shared with every app that uses Permit2), signs an EIP-712 Permit2 message that lets the SubscriptionManager pull USDC, and submits subscribeWithPermit2(planId, ref, permitSingle, signature) from the same wallet. Only the signer can submit it.
5. Webhooks
Levy signs every billing event with Ed25519 and POSTs it to your endpoint. Add endpoints under Webhooks in the dashboard, or from your backend (§9):
POST /api/webhooks
Authorization: Bearer <token>
Content-Type: application/json
{
"url": "https://your-domain.com/hooks/levy",
"events": ["charge.succeeded", "charge.failed", "dunning.started", "subscription.cancelled"]
}
The endpoint must be https on port 443 at a public address. Levy refuses private, loopback and cloud-metadata addresses and does not follow redirects, so register the final URL. Up to 5 endpoints per merchant. Answer within 5 seconds and do slow work after acknowledging.
Every payload carries subId, merchant and ref, plus:
| Event | Extra payload fields |
|---|---|
subscription.created | subscriber, planId, nextChargeAt |
subscription.updated | the customer changed plan: oldPlanId, newPlanId, credit, txHash, blockNumber |
charge.succeeded | amount, fee (6-decimal units; amount is 0 when credit paid the cycle), invoiceRef, chargeCount, txHash, blockNumber |
invoice.ready | the same body as charge.succeeded, sent alongside it |
charge.failed | failedAttempts, txHash, blockNumber |
dunning.started | first failure only: nextRetryAt (unix seconds) |
subscription.paused | auto: true when the contract paused it after repeated failures |
subscription.resumed | none |
subscription.cancelled | none |
allowance.low | subscriber, allowance, balance, cyclesCovered, nextChargeAt |
subscription.resumed and subscription.cancelled have the same body: use the signed X-Levy-Event header to tell them apart. allowance.low is your cue to email the customer before payments start failing. The allowance is one pool shared by all of a customer's Levy subscriptions; the warning is sent once per subscription, and again only after a top-up runs low again. Allowances granted through Permit2 are not counted yet, so a Permit2 subscriber is flagged low from the start.
Failed deliveries are retried after 1 minute, 5 minutes, 15 minutes and 1 hour, then every 6 hours, and dropped after 24 hours. The dashboard shows each endpoint's delivery log. Disabling an endpoint drops what is queued for it, and nothing is queued while it is off.
6. Verify webhook signatures
Every delivery carries these headers:
X-Levy-Signature: base64 Ed25519 signature over{timestamp}.{deliveryId}.{event}.{raw body}X-Levy-Timestamp: unix seconds when this attempt was signed (retries get a new one)X-Levy-Delivery: the delivery id; store it and ignore repeatsX-Levy-Event: the event type, for examplecharge.succeededX-Levy-Key-Id: which signing key was used
The public key, its keyId and the event list are at GET https://app.getlevy.xyz/api/webhooks/public-key. For every delivery:
- Read the raw body exactly as received, before parsing it.
- Reject it if
X-Levy-Timestampis more than 5 minutes from your clock, so a captured delivery cannot be replayed. - Reject it if
X-Levy-Eventis not one of the ten event types. Event names contain dots, and this check stops the signed text being split at a different dot. - Verify
X-Levy-Signatureover{X-Levy-Timestamp}.{X-Levy-Delivery}.{X-Levy-Event}.{raw body}with the public key. - Ignore it if you already processed that
X-Levy-Deliveryid. The id is signed, so this is safe.
In Node.js, with no dependencies:
import { createPublicKey, verify } from "node:crypto";
const API = "https://app.getlevy.xyz";
const { publicKey } = await fetch(API + "/api/webhooks/public-key").then((r) => r.json());
const key = createPublicKey(publicKey);
const EVENTS = new Set(["subscription.created", "subscription.updated", "charge.succeeded",
"charge.failed", "dunning.started", "subscription.paused", "subscription.resumed",
"subscription.cancelled", "invoice.ready", "allowance.low"]);
// rawBody: the request body as a string, before JSON parsing.
function isValid(rawBody, headers) {
const ts = headers["x-levy-timestamp"];
const event = headers["x-levy-event"];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
if (!EVENTS.has(event)) return false;
const signed = Buffer.from(`${ts}.${headers["x-levy-delivery"]}.${event}.${rawBody}`, "utf-8");
return verify(null, signed, key, Buffer.from(headers["x-levy-signature"], "base64"));
}
Levy signs with one key per deployment. Trust a new key only when the public-key endpoint returns it.
7. Invoices
Every successful charge is an invoice record: reference, amount, fee, transaction hash, block number and charge count. Export CSV in the dashboard downloads them all; from your backend:
GET /api/invoices
GET /api/invoices?subscription=3
GET /api/invoices?format=csv
The CSV columns are id, invoiceRef, subId, subscriber, merchant, amountUsdc, feeUsdc, txHash, blockNumber, chargeCount, with amounts in decimal USDC.
8. When a payment fails
A payment fails when the customer's USDC balance or allowance is too low. The contract never reverts on a failed payment: it records the failure on-chain and emits ChargeFailed, never sooner than the schedule below: the contract enforces its gaps, so nobody calling charge can pause a subscription faster. Then:
- Levy retries 1 day after the first failure, 3 days after the second, and every 7 days after that.
- When failures reach the plan's
maxFailed, the subscription is past due. It stays chargeable, and Levy keeps retrying. - At twice
maxFailed, the contract pauses it. Nothing more is collected until you or the customer resume it; billing then restarts from the resume date, and the missed cycle is not collected. - A successful payment at any point clears the failure count. The next billing date moves one cycle on from the missed one, so if more than one cycle was missed, the next charge comes right away.
You get charge.failed on every failure, dunning.started on the first and subscription.paused if the contract pauses it. Levy sends no emails to your customers; use these events to send your own. A customer fixes a failing subscription by adding USDC or topping up the allowance in the portal, and the next retry collects.
9. Sign in from your backend
The dashboard signs you in with your wallet. A backend does the same with Sign-In with Ethereum, using the wallet that owns your plans (an ordinary wallet; contract wallets are not supported yet):
POST /api/auth/challenge {"address": "0xYourWallet"}
-> {"nonce": "...", "message": "..."}
sign "message" with the wallet (personal_sign)
POST /api/auth/verify {"nonce": "...", "signature": "0x..."}
-> {"token": "...", "address": "0x...", "expiresAt": 1790000000}
then send on every call: Authorization: Bearer <token>
A challenge expires after 5 minutes and works once. A token lasts 24 hours; POST /api/auth/logout revokes it. Every endpoint returns only your own data.
10. API reference
Base URL https://app.getlevy.xyz. Public endpoints need no sign-in; the rest need the token from §9.
| Method and path | Access | Returns |
|---|---|---|
GET /api/config | public | chain id and contract addresses |
GET /api/plans | public | every plan |
GET /api/webhooks/public-key | public | the Ed25519 public key, its keyId and the event list |
POST /api/auth/challenge, /api/auth/verify | public | sign-in (§9) |
GET /api/subscriptions?merchant=<you> | signed in | your subscriptions, with ref and allowance runway; &status= filters (0 trial, 1 active, 2 past due, 3 paused, 4 cancelled) |
GET /api/subscriptions/:id | signed in | one subscription with its charges and dunning state |
GET /api/metrics | signed in | MRR (normalised to 30 days), counts per status, totals, charge success rate, dunning depth |
GET /api/dunning | signed in | the retry queue |
GET /api/invoices | signed in | charge records; ?format=csv for CSV |
GET, POST /api/webhooks | signed in | list or register endpoints |
PATCH, DELETE /api/webhooks/:id | signed in | turn an endpoint on or off ({"active": false}), or remove it |
GET /api/deliveries?webhookId= | signed in | the delivery log (&status=failed, &limit= up to 500) |
11. FAQ
How do I change a price? Create a new plan and send new customers to it. Plans cannot be edited, so existing subscriptions keep their price until the customer changes plan in the portal.
How do refunds work? They are your policy and happen off-chain: send USDC back from your own wallet. The contract has no refund function, and a cancelled subscription stays cancelled.
How do I cancel or pause a customer? Use Pause, Resume or Cancel on the subscription in your dashboard; each is a transaction from your wallet. Customers can do the same in the portal, and move to another of your plans with Change plan. Nothing is charged while a subscription is paused, and cancelling is final.
What does the 1% fee apply to? Each collected payment: the contract sends 99% to you and 1% to the protocol in the same transaction. Failed payments cost nothing, and nothing is owed when nothing is collected.
Who pays gas for charges? Levy's keeper pays for the charges it submits. Anyone may call charge once a payment is due, but you never need to.
Can Levy take more than the plan price? No. Each billing date, the contract can collect the plan's price once, only for that plan's merchant, and never before the date. It can never take more than the allowance the customer approved.
Levy runs on Arc testnet and is not audited. Questions or problems: hello@getlevy.xyz. Security reports: see the security policy.