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:

  1. A wallet. Use a browser wallet such as MetaMask. The app asks it to add Arc Testnet the first time it sends a transaction.
  2. 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.
  3. 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.
  4. 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.
  5. 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).
  6. Get notified. Under Webhooks, add your endpoint and choose events, then check every delivery's signature as in §6.
  7. 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

ItemValue
NetworkArc Testnet, chain id 5042002
RPChttps://rpc.testnet.arc.io
Explorerexplorer.testnet.arc.io
SubscriptionManager0x62a6512335d630ef19fcadb497adf98d883f77c6
FeeRouter0x4819975f4fa3a693ec67539bcb121eb06f280541
USDC0x3600000000000000000000000000000000000000 (6 decimals through the ERC-20 interface)
Permit20x000000000022D473030F116dDEE9F6B43aC78BA3
APIhttps://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.

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:

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:

EventExtra payload fields
subscription.createdsubscriber, planId, nextChargeAt
subscription.updatedthe customer changed plan: oldPlanId, newPlanId, credit, txHash, blockNumber
charge.succeededamount, fee (6-decimal units; amount is 0 when credit paid the cycle), invoiceRef, chargeCount, txHash, blockNumber
invoice.readythe same body as charge.succeeded, sent alongside it
charge.failedfailedAttempts, txHash, blockNumber
dunning.startedfirst failure only: nextRetryAt (unix seconds)
subscription.pausedauto: true when the contract paused it after repeated failures
subscription.resumednone
subscription.cancellednone
allowance.lowsubscriber, 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:

The public key, its keyId and the event list are at GET https://app.getlevy.xyz/api/webhooks/public-key. For every delivery:

  1. Read the raw body exactly as received, before parsing it.
  2. Reject it if X-Levy-Timestamp is more than 5 minutes from your clock, so a captured delivery cannot be replayed.
  3. Reject it if X-Levy-Event is not one of the ten event types. Event names contain dots, and this check stops the signed text being split at a different dot.
  4. Verify X-Levy-Signature over {X-Levy-Timestamp}.{X-Levy-Delivery}.{X-Levy-Event}.{raw body} with the public key.
  5. Ignore it if you already processed that X-Levy-Delivery id. 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:

  1. Levy retries 1 day after the first failure, 3 days after the second, and every 7 days after that.
  2. When failures reach the plan's maxFailed, the subscription is past due. It stays chargeable, and Levy keeps retrying.
  3. 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.
  4. 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 pathAccessReturns
GET /api/configpublicchain id and contract addresses
GET /api/planspublicevery plan
GET /api/webhooks/public-keypublicthe Ed25519 public key, its keyId and the event list
POST /api/auth/challenge, /api/auth/verifypublicsign-in (§9)
GET /api/subscriptions?merchant=<you>signed inyour subscriptions, with ref and allowance runway; &status= filters (0 trial, 1 active, 2 past due, 3 paused, 4 cancelled)
GET /api/subscriptions/:idsigned inone subscription with its charges and dunning state
GET /api/metricssigned inMRR (normalised to 30 days), counts per status, totals, charge success rate, dunning depth
GET /api/dunningsigned inthe retry queue
GET /api/invoicessigned incharge records; ?format=csv for CSV
GET, POST /api/webhookssigned inlist or register endpoints
PATCH, DELETE /api/webhooks/:idsigned inturn an endpoint on or off ({"active": false}), or remove it
GET /api/deliveries?webhookId=signed inthe 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.