Subscriptions & metering

Bounded approval for recurring payments

A payment mandate is a standing spending approval: the payer approves a limited amount and a payment schedule. The existing EOA protocol uses one token permit; the gated contract protocol below requires separate signed terms and permit approvals. 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.

Beta: new mandates and subscriptions are locked

During the GenesisPay beta, creating new mandate authority is not available. Proposing or activating a mandate, proposing a new mandate-backed charge, a mandate-backed agent purchase, subscription renewals and POST /api/v1/plans all answer 409 with mandate_authority_locked_for_beta and no Retry-After — waiting does not change the answer.

Everything a payer has already approved keeps working: submitting a signed charge, revocation, status and list reads, reconciliation and settlement are unaffected, and a retry of a request made before the lock still returns its original identity rather than a new payment. One-off checkout and agent x402 pay-per-use are outside the lock entirely.

Contract subscriptions: explicit two-signature protocol

The contract flow is gated and off by default. It currently implements human subscription approval, automatic billing and permanent revocation. Operational recovery, cutover and release evidence must be completed before enablement. Mainnet, including a development deployment on Base Mainnet, requires legal review, an audit and the legacy-authority cutover. The one-permit examples below describe the existing EOA protocol and must not be reused for a contract mandate.

ts
const proposal = await genesispay.mandates.createContractSubscription({
  payerWallet, allowance: "108", capPerCharge: "9", amountPerPeriod: "9",
  periodDays: 30, validUntil: "2027-09-01T00:00:00Z",
});
// Payer frontend: sign proposal.approval.termsTypedData first,
// then proposal.approval.permitTypedData. Submit both exact signatures:
await fetch(baseUrl + "/api/v1/mandates/" + proposal.mandate.id + "/activate", {
  method: "POST", headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ protocol: "contract_mandate_v1", termsSignature, permitSignature }),
});

Direct API creation uses POST /api/v1/mandates withprotocol: "contract_mandate_v1", kind: "subscription", the usual bounded amounts and an explicit ISO validUntil. The response contains mandate, spender andapproval with both typed payloads. Each binds the payer, native asset, chain and immutable contract version. Terms also bind seller, fee recipient and rate, amount, cadence, total cap and validity.

HTTP202 means accepted; only canonical confirmation makes the mandate active. Poll the authenticated seller retrieve endpoint or the owned payer dashboard. Retry identical signatures after an unknown response. A409mandate_proposal_stale requires a new proposal and new approval; never alter signed terms or silently switch an existing EOA approval to a contract. Capacity admission precedes proposal issuance, so Redis failure cannot block signed submission.

Seller/session POST /api/v1/mandates/:id/revoke requests a local stop and returns202 until on-chain revocation is confirmed. The payer signs the API mandate's revocationTypedData. Any relay may submit{ protocol: "contract_mandate_v1", signature } toPOST /api/v1/mandates/:id/revoke-signed without a session or API key. The bound executor also accepts revokeWithSignature(payer, mandateId, signature)directly, or revoke(mandateId) from the payer. The dashboard can export the signed transaction for an independent relay. Requested cancellation is not the confirmed permanent tombstone; previously approved payments may execute until that confirmation. Agent per-use authorization is a separate gated flow; this protocol does not grant general signing access.

Contract billing uses the first eligible charge immediately, then the signed interval after each successful chain confirmation. It never catches up missed periods. Seller and fee confirmation commit with the billing webhook. Contract deliveries include immutable contractEvent facts and a separatemandateStateContext identifying the mandate object as a projection-time snapshot. occurredAt is the historical block time. Events can arrive out of order; an older activation event can carry an already revoked snapshot. Deduplicate the event ID and retrieve current state before enabling access.

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 transferFrom pull 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.
  • cancelled — terminal state for an unbroadcast permit stopped because the seller became unavailable. It never activates after recovery; propose a new mandate for a fresh payer signature.

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 "$GENESISPAY_BASE_URL/api/v1/plans" \
  -H "Authorization: Bearer gp_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 amountPerPeriod per charge, so no single pull can ever exceed one period's price.

Pass an optional returnUrl to 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 (gp_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 "$GENESISPAY_BASE_URL/api/v1/mandates" \
  -H "Authorization: Bearer gp_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": "0xGenesisPayRelayer",
      "value": "20000000",
      "nonce": "3",
      "deadline": "1784898000"
    }
  },
  "spender": "0xGenesisPayRelayer"
}

2. Payer signs, then activate

Hand permitTypedData to 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 "$GENESISPAY_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 "$GENESISPAY_BASE_URL/api/v1/mandates/<mandate-id>/charge" \
  -H "Authorization: Bearer gp_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 "$GENESISPAY_BASE_URL/api/v1/mandates/<mandate-id>" \
  -H "Authorization: Bearer gp_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 "$GENESISPAY_BASE_URL/api/v1/mandates/<mandate-id>/revoke" \
  -H "Authorization: Bearer gp_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 "$GENESISPAY_BASE_URL/api/v1/mandates?planId=sub_9lQ1x0m4Tz2vJfKq8Yb3dA&status=active&limit=100" \
  -H "Authorization: Bearer gp_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.GENESISPAY_BASE_URL!;
const headers = { authorization: `Bearer ${process.env.GENESISPAY_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/genesispay-seller ships a mandate gate that meters an endpoint per request. Callers present their mandate id in the GENESISPAY-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 GENESISPAY-MANDATE-CHARGE. Requests without the header get a 402 JSON body explaining how to attach one. Every charge carries your seller key, so baseUrl must be https (plain http only for localhost or 127.0.0.1); seller SDK 1.8.2 refuses anything else when the gate is created.

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

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

// Callers attach their mandate id via the GENESISPAY-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 nextChargeAt is due. It is triggered by your deployment's cron (and manually in development) and guarded by a shared secret — set GENESISPAY_CRON_SECRET in the environment (generate it with gp_cron_$(openssl rand -hex 32); a deployment refuses one shorter than 32 characters), or the endpoint responds 503:

Terminal
curl -X POST "$GENESISPAY_BASE_URL/api/v1/mandates/run-due" \
  -H "X-GENESISPAY-CRON-SECRET: $GENESISPAY_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.