Subscriptions & metering

One signature, then signature-free charges

A payment mandateis a standing spending approval: the payer signs one gasless EIP-2612 permit, and from then on you charge per use or per period without asking for another signature. It is non-custodial throughout — funds stay in the payer's wallet until each individual charge, then move directly from the payer to you. GenesisPay never holds the money, and the payer can cancel at any time.

How it works

Per-call x402 payments require a fresh signed authorization for every request — fine for agents, unusable for a human who wants to pay $0.08 per lookup or $9/month. Mandates solve this with an on-chain allowance: the payer's signed permit grants GenesisPay's relayer a bounded spending approval (the allowance), and each charge is a transferFrompull that moves exactly that charge's amount from the payer's wallet straight to your destination wallet. Two hard limits protect the payer: no single charge can exceed capPerCharge, and total spend can never exceed allowance. Revoking the mandate stops all future charges.

Mandate lifecycle

  • pending_permit— proposed by you, waiting for the payer's signature. The permit is valid for one hour (permitDeadline); after that the mandate can no longer be activated and you propose a new one.
  • active — the signed permit was submitted on-chain; charges are allowed.
  • past_due — a subscription renewal failed to pull (allowance used up or payer balance too low). The mandate is still chargeable: a later successful charge flips it back to active.
  • revoked — cancelled by you or the payer; no further charges.
  • expired — terminal state for mandates whose permit window lapsed without activation.

Three pricing models

Per-use metering for humans kind: "per_use". The customer approves a budget once (e.g. $20 with a $0.10 per-charge cap) and you debit $0.08 per request. No wallet prompt per call.

Monthly subscriptions kind: "subscription" with amountPerPeriod and periodDays. The scheduler pulls the fixed amount each period until the allowance runs out or the payer cancels. For a recurring product you sell to many customers, create a plan instead of proposing a mandate per customer.

Per-call x402 for agents — unchanged and complementary. Agents keep paying per request with a signed x402 payment (see the seller quickstart); the same endpoint can additionally accept mandate-metered calls via the mandate gate.

Subscription plans over the API

A plan is a reusable billing template — amount, period, and a hosted page the customer signs on. Plans are no longer dashboard-only: POST /api/v1/plans creates one with your seller key, and GET / POST …/archive read and retire them. Full request and response shapes are in the API reference.

Terminal
curl -X POST "$PEERPAY_BASE_URL/api/v1/plans" \
  -H "Authorization: Bearer pp_sk_your_seller_key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Pro plan",
    "description": "Monthly access",
    "amountPerPeriod": "9.00",
    "periodDays": 30,
    "prepaidCycles": 12
  }'
201 Created
{
  "plan": {
    "id": "plan-uuid",
    "publicId": "sub_9lQ1x0m4Tz2vJfKq8Yb3dA",
    "title": "Pro plan",
    "description": "Monthly access",
    "asset": "USDC",
    "amountPerPeriod": "9.00",
    "amountPerPeriodMinor": "9000000",
    "periodDays": 30,
    "prepaidCycles": 12,
    "capPerChargeMinor": "9000000",
    "allowanceMinor": "108000000",
    "destinationWallet": "0x...",
    "chainId": 84532,
    "status": "active",
    "checkoutUrl": "https://genesispay.example/subscribe/sub_9lQ1x0m4Tz2vJfKq8Yb3dA",
    "returnUrl": null,
    "createdAt": "2026-07-21T09:00:00.000Z",
    "archivedAt": null
  }
}

checkoutUrl is the hosted subscribe flow — /subscribe/:publicId. Hand it to the customer (link, email, button); they review the plan, connect a wallet, and sign one gasless permit. That signature activates a subscription mandate for allowanceMinor = amountPerPeriod × prepaidCycles, capped at amountPerPeriodper charge, so no single pull can ever exceed one period's price.

Pass an optional returnUrlto send the customer back to your app once they finish signing — the subscription analogue of a payment link's returnUrl. It must be https (or http on localhost); leave it unset and the subscriber lands on their GenesisPay subscriptions instead.

From there the renewal scheduler takes over: POST /api/v1/mandates/run-due charges every subscription whose nextChargeAt is due. You do not write a monthly cron and you do not stitch mandates together by hand — the whole chain is:

  1. POST /api/v1/plans — create the plan once.
  2. Send the customer the checkoutUrl from the response.
  3. The customer signs on /subscribe/:publicId; the mandate goes active and you get a mandate.active webhook.
  4. The scheduler pulls each period and emits subscription.renewed (or subscription.past_due).
  5. POST /api/v1/plans/:publicId/archive stops new subscribers; existing mandates keep billing until the payer cancels.

The numbered steps below are the lower-level path: propose a mandate for one named payer and collect the signature in your own frontend. Use it for per-use metering, or when you need a mandate that no plan describes.

1. Propose a mandate

Create the mandate with your seller key (pp_sk_..., same auth and env vars as the seller quickstart). Amounts are decimal strings; asset is USDC (default) or EURC, and destinationWallet defaults to your receiving wallet.

Terminal
curl -X POST "$PEERPAY_BASE_URL/api/v1/mandates" \
  -H "Authorization: Bearer pp_sk_your_seller_key" \
  -H "Content-Type: application/json" \
  -d '{
    "payerWallet": "0xPayerWallet",
    "kind": "per_use",
    "allowance": "20",
    "capPerCharge": "0.10"
  }'

For a subscription, add the period fields — this example allows twelve $9 renewals, 30 days apart:

Subscription body
{
  "payerWallet": "0xPayerWallet",
  "kind": "subscription",
  "allowance": "108",
  "capPerCharge": "9",
  "amountPerPeriod": "9",
  "periodDays": 30
}

The response contains the serialized mandate (minor units, 6 decimals), the exact EIP-712 payload the payer must sign, and the relayer address that acts as spender:

201 Created
{
  "mandate": {
    "id": "mandate-uuid",
    "kind": "per_use",
    "status": "pending_permit",
    "asset": "USDC",
    "chainId": 84532,
    "payerWallet": "0x...",
    "destinationWallet": "0x...",
    "subscriptionPlanId": null,
    "allowanceMinor": "20000000",
    "capPerChargeMinor": "100000",
    "spentMinor": "0",
    "remainingMinor": "20000000",
    "amountPerPeriodMinor": null,
    "periodDays": null,
    "nextChargeAt": null,
    "permitDeadline": "2026-07-19T13:00:00.000Z",
    "permitTxHash": null,
    "createdAt": "2026-07-19T12:00:00.000Z",
    "activatedAt": null,
    "revokedAt": null
  },
  "permitTypedData": {
    "domain": {
      "name": "USDC",
      "version": "2",
      "chainId": 84532,
      "verifyingContract": "0x..."
    },
    "types": {
      "Permit": [
        { "name": "owner", "type": "address" },
        { "name": "spender", "type": "address" },
        { "name": "value", "type": "uint256" },
        { "name": "nonce", "type": "uint256" },
        { "name": "deadline", "type": "uint256" }
      ]
    },
    "primaryType": "Permit",
    "message": {
      "owner": "0xPayerWallet",
      "spender": "0xPeerPayRelayer",
      "value": "20000000",
      "nonce": "3",
      "deadline": "1784898000"
    }
  },
  "spender": "0xPeerPayRelayer"
}

2. Payer signs, then activate

Hand permitTypedDatato the payer's wallet unchanged. The signature is gasless — the payer needs no ETH, and nothing moves yet:

Payer side
// In the payer's context — any EIP-712 capable wallet.
// permitTypedData comes straight from the 201 response above.
const signature = await walletClient.signTypedData(permitTypedData);
// (eth_signTypedData_v4 under the hood; Privy embedded wallets
// expose the same via useSignTypedData.)

POST the signature back to activate the mandate. This endpoint needs no API key: the signature itself is the authorization — only the payer's real signature can grant an allowance from their wallet, and a forged one simply reverts on-chain. GenesisPay's relayer submits the permit (and pays the gas):

Terminal
curl -X POST "$PEERPAY_BASE_URL/api/v1/mandates/<mandate-id>/activate" \
  -H "Content-Type: application/json" \
  -d '{ "signature": "0x<65-byte-signature>" }'
200 OK
{
  "mandate": { "id": "mandate-uuid", "status": "active", ... },
  "permitTxHash": "0x..."
}

3. Charge per use

Once active, debit the mandate whenever the customer consumes something. No payer interaction — the relayer pulls the amount directly from the payer's wallet to your destination wallet and the response carries the transaction hash:

Terminal
curl -X POST "$PEERPAY_BASE_URL/api/v1/mandates/<mandate-id>/charge" \
  -H "Authorization: Bearer pp_sk_your_seller_key" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": "0.08",
    "resourceUrl": "https://api.example.com/lookup",
    "description": "Premium data lookup"
  }'
200 OK
{
  "charge": {
    "id": "charge-uuid",
    "status": "settled",
    "amountMinor": "80000",
    "txHash": "0x..."
  }
}

Failures return { error, code }: 402 with codes like mandate_not_active, cap_per_charge_exceeded, allowance_exhausted, or payer_balance_too_low; 403 not_mandate_owner if the mandate belongs to another seller; 404 mandate_not_found.

402 Payment Required
{ "error": "...", "code": "allowance_exhausted" }

4. Check entitlement

Answer “is this customer's mandate active and how much approval is left?” without pulling a charge:

Terminal
curl "$PEERPAY_BASE_URL/api/v1/mandates/<mandate-id>" \
  -H "Authorization: Bearer pp_sk_your_seller_key"

Returns the same mandate shape as creation — status, spentMinor, remainingMinor (allowance minus spend), and for subscriptions nextChargeAt.

5. Revoke

Cancel a mandate with your seller key, or from the dashboard while signed in. The payer can always cancel their own mandates from the dashboard — a payer must be able to stop future charges at any time. Revocation is immediate and final:

Terminal
curl -X POST "$PEERPAY_BASE_URL/api/v1/mandates/<mandate-id>/revoke" \
  -H "Authorization: Bearer pp_sk_your_seller_key"

If you do not have the mandate id at hand — a customer writes in and all you know is their plan and wallet — start from the cancellation flow below.

Cancel a subscription: list, find, revoke

revoke needs a mandate id, and until now the only place that id ever appeared was the mandate.active webhook. A missed delivery — an outage, a redeploy, a bug in your handler — left the subscription invisible to you, and a customer asking to cancel could not be served. GET /api/v1/mandates closes that: your mandates are queryable at any time, filtered by plan and status.

1. List the plan's subscribers. planId is the plan's public id (the sub_… value from the plan response and checkoutUrl), not the internal uuid. An unknown id, or a plan belonging to another account, returns an empty page rather than a 404.

Terminal
curl "$PEERPAY_BASE_URL/api/v1/mandates?planId=sub_9lQ1x0m4Tz2vJfKq8Yb3dA&status=active&limit=100" \
  -H "Authorization: Bearer pp_sk_your_seller_key"
200 OK
{
  "mandates": [
    {
      "id": "9f1c2b7e-2f4a-4a8f-9d1e-6b0c5a3d7e21",
      "kind": "subscription",
      "status": "active",
      "asset": "USDC",
      "chainId": 84532,
      "payerWallet": "0xPayerWallet",
      "destinationWallet": "0x...",
      "subscriptionPlanId": "plan-uuid",
      "allowanceMinor": "108000000",
      "capPerChargeMinor": "9000000",
      "spentMinor": "18000000",
      "remainingMinor": "90000000",
      "amountPerPeriodMinor": "9000000",
      "periodDays": 30,
      "nextChargeAt": "2026-08-20T09:00:00.000Z",
      "permitDeadline": "2026-06-21T10:00:00.000Z",
      "permitTxHash": "0x...",
      "createdAt": "2026-06-21T09:00:00.000Z",
      "activatedAt": "2026-06-21T09:00:12.000Z",
      "revokedAt": null
    }
  ],
  "hasMore": false
}

2. Find the right mandate. Match on payerWallet — the wallet the customer signed with, and the identifier they can quote to your support. One payer can hold several mandates on the same plan (a resubscribe after a revoke), so filter to status=active and, if more than one is left, take the newest — the list is already ordered newest first.

3. Revoke it. POST /api/v1/mandates/:id/revoke returns 200 with the mandate in status revoked. A mandate that is not yours answers 403 “Only the mandate's seller or payer can cancel it.”, an unknown id 404“Mandate not found.” Revoking twice is not an error, but it is not free either: the second call rewrites revokedAt to the later timestamp and emits another mandate.revoked webhook, so dedupe on your side rather than retrying blindly.

cancel-subscription.ts
const baseUrl = process.env.PEERPAY_BASE_URL!;
const headers = { authorization: `Bearer ${process.env.PEERPAY_SELLER_KEY!}` };

// 1. Every active subscriber of this plan, page by page.
async function* subscribers(planId: string) {
  let startingAfter: string | undefined;
  let hasMore = true;

  while (hasMore) {
    const url = new URL("/api/v1/mandates", baseUrl);
    url.searchParams.set("planId", planId);   // the plan's sub_ public id
    url.searchParams.set("status", "active");
    url.searchParams.set("limit", "100");
    if (startingAfter) url.searchParams.set("startingAfter", startingAfter);

    const page = await (await fetch(url, { headers })).json();
    yield* page.mandates;

    // Cursor = the last id of the page just read (ordering: createdAt DESC, id DESC).
    startingAfter = page.mandates.at(-1)?.id;
    hasMore = page.hasMore && startingAfter !== undefined;
  }
}

// 2. Find the customer's mandate. The payer wallet is the identifier the
//    customer shares with you; payerWallet is checksummed, so compare
//    case-insensitively.
const wallet = "0xPayerWallet"; // whatever the customer gave support
let mandate;
for await (const candidate of subscribers("sub_9lQ1x0m4Tz2vJfKq8Yb3dA")) {
  if (candidate.payerWallet.toLowerCase() === wallet.toLowerCase()) {
    mandate = candidate;
    break;
  }
}

if (!mandate) throw new Error("No active mandate for that wallet on this plan.");

// 3. Cancel it. Immediate and final — no further charges are pulled.
await fetch(`${baseUrl}/api/v1/mandates/${mandate.id}/revoke`, {
  method: "POST",
  headers,
});

Paginate, do not assume one page. The default limit is 25 and the maximum is 100; a plan with more subscribers than that comes back with hasMore: true. Paging is keyset-based: pass the id of the last mandate of a page as startingAfter to get the next one. The ordering is createdAt DESC, id DESC, so nothing is skipped or repeated even when several mandates share a timestamp. Parameters and error shapes are in the API reference.

subscriptionPlanId is what links a mandate to a plan — it is set only when the mandate was signed on a plan's hosted /subscribe/:planId checkout. A mandate you proposed yourself through POST /api/v1/mandates has null there; that endpoint ignores a plan id in the body, so no caller can attribute a mandate to a plan. Those mandates match no planId filter — list without the parameter to see them. The field is echoed on every mandate response and on the mandate webhook payload, so a mandate.active handler can tell which plan was subscribed to without a lookup.

Gate your API on a mandate

@genesis-tech/peerpay-seller ships a mandate gate that meters an endpoint per request. Callers present their mandate id in the PEERPAY-MANDATE request header; the gate charges the mandate first and only runs your handler once the charge settled, stamping the charge id on the response as PEERPAY-MANDATE-CHARGE. Requests without the header get a 402 JSON body explaining how to attach one.

route.ts
import { createMandateGate } from "@genesis-tech/peerpay-seller";

const gate = createMandateGate({
  amount: "0.08", // decimal charge per request
  baseUrl: process.env.PEERPAY_BASE_URL!,
  apiKey: process.env.PEERPAY_SELLER_KEY!, // pp_sk_...
  description: "Premium data lookup",
});

// Callers attach their mandate id via the PEERPAY-MANDATE header.
export const GET = gate.wrap(
  async () => Response.json({ data: "paid content" }),
);

gate.charge(mandateId, resourceUrl?) is exposed for custom flows. Combine with createPaymentGate from the seller quickstart to accept both per-call x402 payments and mandate-metered calls on one endpoint.

Subscription renewals (scheduler)

Subscription mandates are charged by a billing run that pulls amountPerPeriod from every active subscription whose nextChargeAtis due. It is triggered by your deployment's cron (and manually in development) and guarded by a shared secret — set PEERPAY_CRON_SECRET in the environment, or the endpoint responds 503:

Terminal
curl -X POST "$PEERPAY_BASE_URL/api/v1/mandates/run-due" \
  -H "X-PEERPAY-CRON-SECRET: $PEERPAY_CRON_SECRET"
200 OK
{
  "processed": 2,
  "settled": 1,
  "failed": 1,
  "results": [
    { "mandateId": "mandate-uuid", "ok": true, "txHash": "0x..." },
    { "mandateId": "other-uuid", "ok": false, "code": "payer_balance_too_low" }
  ]
}

One run covers every subscription mandate you own, whether it was proposed through /api/v1/mandates or signed on a plan's hosted checkout — there is nothing per-plan to schedule.

Renewals that cannot pull (allowance used up, balance empty) mark the mandate past_due and emit a subscription.past_due webhook instead of failing silently every period.

Webhook events

Mandate events are delivered through the same signed webhook mechanism as payment events: mandate.active, mandate.charged, mandate.charge_failed, mandate.revoked, subscription.renewed, and subscription.past_due. data carries the mandate plus the charge (or null for lifecycle events without one):

Delivery body
{
  "id": "delivery-uuid",
  "type": "mandate.charged",
  "createdAt": "2026-07-19T12:00:01.000Z",
  "data": {
    "mandate": {
      "id": "mandate-uuid",
      "kind": "per_use",
      "status": "active",
      "payerWallet": "0x...",
      "subscriptionPlanId": null,
      "asset": "USDC",
      "allowanceMinor": "20000000",
      "capPerChargeMinor": "100000",
      "spentMinor": "80000",
      "amountPerPeriodMinor": null,
      "periodDays": null,
      "nextChargeAt": null
    },
    "charge": {
      "id": "charge-uuid",
      "kind": "usage",
      "amountMinor": "80000",
      "status": "settled",
      "txHash": "0x...",
      "resourceUrl": "https://api.example.com/lookup",
      "failureReason": null
    },
    "occurredAt": "2026-07-19T12:00:00.000Z"
  }
}

data.mandate.subscriptionPlanId is the plan the mandate was signed on, or null for a mandate proposed directly through POST /api/v1/mandates. It is on every mandate event, so a mandate.active handler knows what was subscribed to without a second request. If you miss a delivery, the same mandates are readable from GET /api/v1/mandates.

Caveats

  • Charges settle on-chain, per pull. Every charge is a real transferFrom transaction the relayer submits and waits on — expect a second or two per charge, not sub-millisecond metering. The mandate gate charges before your handler runs, so that latency is on the request path.
  • The payer must keep enough balance.Funds stay in the payer's wallet until each pull; if the balance dips below a charge, the charge fails with payer_balance_too_low. The allowance is an approval, not a reservation.
  • past_due means a renewal could not pull — the subscription has not silently ended, but the payer needs to top up (or approve a new mandate once the allowance is exhausted). You get a subscription.past_due webhook; a later successful charge returns the mandate to active.
  • Allowances are finite. When spentMinor reaches the allowance, charges fail with allowance_exhausted; propose a fresh mandate for the payer to sign.