API reference

The /api/v1 surface

Authenticated endpoints take Authorization: Bearer <secret> — seller endpoints use pp_sk_ keys, agent endpoints use pp_ag_ keys. Keys are created in the dashboard, shown once, and revocable; GenesisPay stores only SHA-256 hashes. Money is always USDC on Base: decimal strings in requests, integer minor-unit strings (6 decimals) in responses. Errors are JSON { "error", "code?" }; requests over the per-key or per-IP rate limit get 429.

Seller API (pp_sk_)

POST/api/v1/links

Create a payment link.

Request
{
  "title": "Market report",          // required, <= 120 chars
  "description": "Optional note",    // optional, <= 1000 chars
  "amount": "5.00",                  // required, decimal string in "asset"
  "asset": "USDC" | "EURC",          // optional, default "USDC"
  "destinationWallet": "0x...",      // optional, defaults to your receiving wallet
  "linkType": "single" | "reusable", // optional, default "single"
  "metadata": { "orderId": "A-1042" },        // optional, flat string map
  "clientReferenceId": "order_1042",          // optional, <= 200 chars
  "returnUrl": "https://shop.example/thanks", // optional, https
  "cancelUrl": "https://shop.example/cart"    // optional, https
}
Response
201 { "link": { "publicId", "payUrl", "title", "description",
  "amount", "amountUsdc", "amountUsdcMinor", "asset", "destinationWallet",
  "chainId", "linkType", "status", "metadata", "clientReferenceId",
  "returnUrl", "cancelUrl", "createdAt", "archivedAt" } }
422 { "error", "issues": [{ "path", "message" }] }

amount is the decimal amount in the link's asset — euros on an EURC link, dollars on a USDC one. amountUsdc is the deprecated older name for the same field, still accepted on request and still returned on response; sending both is fine only when they are the same amount, otherwise the request is rejected. Reusable links stay active and accept unlimited confirmed payments; single links are marked paid after one confirmation. metadata, clientReferenceId, returnUrl, and cancelUrl are stored untouched and echoed by every read; unset fields come back as null. See the field limits below.

GET/api/v1/links

List your links with payment counts.

Response
200 { "links": [ { ...link, "confirmedPaymentCount" } ] }

Scoped to the seller key's account. ...link is the create response shape, metadata / clientReferenceId / returnUrl / cancelUrl included.

GET/api/v1/links/:publicId

Link detail including payment attempts.

Response
200 { "link": { ...link, "metadata", "clientReferenceId",
  "returnUrl", "cancelUrl", "confirmedPaymentCount",
  "attempts": [{ "id", "payerWallet", "expectedAmountUsdcMinor",
    "txHash", "status", "createdAt", "expiresAt",
    "confirmedAt", "failureReason", "simulated" }] } }
404 { "error": "Payment link not found." }

Attempts include payer wallet, amount, status, and transaction hash. This is what the SDK's checkout.retrieve() reads; confirmedPaymentCount > 0 is what it exposes as paid. simulated is true for attempts minted by the test-mode simulate-payment endpoint below, and those carry txHash: null.

POST/api/v1/facilitator/settle

Settle a signed x402 payment on-chain.

Request
{
  "paymentSignature": "<PAYMENT-SIGNATURE header value>",
  "requirement": {
    "resource": "https://...", "network": "base-sepolia",
    "chainId": 84532, "assetAddress": "0x...",
    "amountUsdcMinor": "100000", "payTo": "0x...",
    "description": "optional"
  }
}
Response
200 PAYMENT-RESPONSE success (settled + verified)
402 PAYMENT-RESPONSE failure (verification or settlement failed)
503 { "error" } when no facilitator key is configured

Used by @genesis-tech/peerpay-seller's peerPaySettlement helper. GenesisPay broadcasts the EIP-3009 transferWithAuthorization and verifies the USDC transfer before reporting success. Settlement is idempotent per authorization nonce.

Subscription plans (pp_sk_)

A subscription plan is a reusable billing template: an amount, a period, and a hosted page the customer signs their mandate on. Plans used to be dashboard-only — these four routes create, read, and archive them with the same pp_sk_ key as everything else, scoped to your account. Public ids are prefixed sub_.

POST/api/v1/plans

Create a subscription plan.

Request
{
  "title": "Pro plan",              // required, <= 120 chars
  "description": "Monthly access",  // optional, <= 1000 chars
  "amountPerPeriod": "9.00",        // required, decimal string
  "periodDays": 30,                 // required, 1..366
  "prepaidCycles": 12,              // optional, 1..120, default 12
  "destinationWallet": "0x...",     // optional, defaults to your receiving wallet
  "asset": "USDC"                   // optional, "USDC" (default) or "EURC"
}
Response
201 { "plan": { "id", "publicId", "title", "description", "asset",
  "amountPerPeriod", "amountPerPeriodMinor", "periodDays",
  "prepaidCycles", "capPerChargeMinor", "allowanceMinor",
  "destinationWallet", "chainId", "status",
  "checkoutUrl": "https://.../subscribe/sub_...",
  "createdAt", "archivedAt" } }
422 { "error", "issues": [{ "path", "message" }] }

periodDays and prepaidCycles accept a number or a numeric string. capPerChargeMinor equals amountPerPeriodMinor, allowanceMinor is amountPerPeriodMinor × prepaidCycles — that product is the total the payer pre-authorizes with one signature. destinationWallet is required if your account has no receiving wallet configured; the 422 issue says so.

GET/api/v1/plans

List your plans.

Response
200 { "plans": [ { ...plan } ] }

Scoped to the seller key's account, archived plans included (check status). ...plan is the create response shape, checkoutUrl included.

GET/api/v1/plans/:publicId

Read one plan.

Response
200 { "plan": { ...plan } }
404 { "error": "Subscription plan not found." }

A plan belonging to another account answers 404, not 403 — the API is no plan-enumeration oracle.

POST/api/v1/plans/:publicId/archive

Archive a plan.

Response
200 { "plan": { ...plan, "status": "archived", "archivedAt": "..." } }
404 { "error": "Subscription plan not found." }

The hosted /subscribe page stops accepting new subscribers; mandates already signed from the plan keep billing until the payer cancels. Archiving twice is a no-op that returns the plan unchanged — a retry never moves the recorded archivedAt.

checkoutUrl is the hosted subscribe flow — the /subscribe/:publicId page where the customer reviews the plan and signs the permit that activates their mandate. Every plan response carries it, so you never build that URL yourself. Send it to the customer, and from then on renewals are the mandate scheduler's job — no per-period cron of your own.

Charging a mandate directly, checking entitlement, and revoking are documented in the subscriptions & metering guide.

List mandates (pp_sk_)

A mandate id used to reach you only through the mandate.active webhook — miss the delivery and the subscription was invisible, including to a customer asking you to cancel it. GET /api/v1/mandates is the read side: your mandates, newest first, filterable by plan and status.

GET/api/v1/mandates

List your mandates, newest first.

Request
?planId=sub_9lQ1x0m4Tz2vJfKq8Yb3dA  // optional, a plan's public id
&status=active                        // optional, one mandate status
&limit=25                             // optional, 1..100 (default 25)
&startingAfter=<mandate id>           // optional keyset cursor
Response
200 { "mandates": [ { "id", "kind", "status", "asset", "chainId",
    "payerWallet", "destinationWallet", "subscriptionPlanId",
    "allowanceMinor", "capPerChargeMinor", "spentMinor",
    "remainingMinor", "amountPerPeriodMinor", "periodDays",
    "nextChargeAt", "permitDeadline", "permitTxHash",
    "createdAt", "activatedAt", "revokedAt" } ],
  "hasMore": false }
400 { "error", "issues": [{ "path", "message" }] }
400 { "error": "startingAfter is not a mandate of this account." }

Scoped to the seller key's account: mandates belonging to someone else are not forbidden, they are simply not in the result. Every status is included unless you filter. Ordering is createdAt DESC, id DESC — stable even when two mandates share a timestamp, which is what makes the cursor safe.

Query parameterRules
planIdA plan's public id (sub_…), not its internal uuid — the id in every plan response and in checkoutUrl. Returns only mandates signed on that plan's hosted checkout. An unknown id, or a plan owned by another account, returns an empty page with hasMore: false — never a 404, so the filter cannot be used to probe which plans exist.
statusOne of pending_permit, active, past_due, revoked, expired. Anything else is a 400 with the accepted values in the issues array.
limitInteger 1–100, default 25. Accepts a numeric string. Out of range is a 400 raised before the query runs.
startingAfterThe id of the last mandate of the previous page. Must be a mandate id (anything else is a 400 with path startingAfter) and must belong to your account — a foreign or unknown id is a 400 with "startingAfter is not a mandate of this account.", not an empty page.

Paging is keyset-based, not offset-based. Rows come back ordered by createdAt DESC, id DESC; startingAfter names the last mandate you saw and the next page starts strictly below it in that order. hasMore tells you whether another page exists — it is computed by reading one row past limit, so there is no total count to ask for. The tie-break on id is what keeps a mandate from being skipped or returned twice when two rows share a createdAt.

Read every page
const baseUrl = process.env.PEERPAY_BASE_URL!;
const headers = { authorization: `Bearer ${process.env.PEERPAY_SELLER_KEY!}` };

const mandates = [];
let startingAfter: string | undefined;
let hasMore = true;

while (hasMore) {
  const url = new URL("/api/v1/mandates", baseUrl);
  url.searchParams.set("limit", "100");
  if (startingAfter) url.searchParams.set("startingAfter", startingAfter);

  const response = await fetch(url, { headers });
  if (!response.ok) throw new Error(await response.text());
  const page = await response.json();

  mandates.push(...page.mandates);

  // The cursor is the id of the LAST row you just read — not an offset and
  // not a page number. Guard on it too: hasMore with an empty page would
  // otherwise loop forever.
  startingAfter = page.mandates.at(-1)?.id;
  hasMore = page.hasMore && startingAfter !== undefined;
}

subscriptionPlanId on each mandate is the internal id of the plan it was signed on, or null when the mandate was proposed directly through POST /api/v1/mandates — that path never sets it, so a caller cannot attribute a mandate to a plan. Filter by plan with the planId query parameter (the public sub_ id), not by comparing this field. The same field travels on mandate webhooks under data.mandate.subscriptionPlanId.

Cancelling a subscription end to end — list a plan's subscribers, pick the mandate, revoke it — is walked through in the subscriptions guide. Proposing, activating, and charging mandates are documented there too.

Test mode: simulate a payment

simulate-payment records a confirmed payment on one of your links without any transaction happening, and fires the real webhooks — payment.confirmed, plus link.paid for a single-use link. That is the point: you can exercise your webhook handler end to end without a funded wallet.

POST/api/v1/links/:publicId/simulate-payment

Mint a confirmed payment for one of your links without money moving.

Request
{
  "payerWallet": "0x..."   // optional, defaults to
                           // 0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef
}
Response
201 { "attempt": { "id", "payerWallet", "expectedAmountUsdcMinor",
    "txHash": null, "status": "confirmed", "simulated": true,
    "createdAt", "expiresAt", "confirmedAt", "failureReason" },
  "link": { ...link } }
400 { "error": "Request body must be valid JSON." }
403 { "error" }   // live key
404 { "error": "Not found." }               // mainnet deployment
404 { "error": "Payment link not found." }  // not your link
409 { "error" }   // single link already paid, or link archived
422 { "error", "issues": [{ "path", "message" }] }

The body is optional; an empty request is fine. A single link is marked paid, a reusable link stays active — the same split as a real confirmation.

Test keys only. The endpoint works exclusively with a pp_sk_test_… key; a live key gets 403 with a message saying so. Live keys can only ever record real, on-chain payments.

It does not exist on mainnet. A mainnet deployment answers 404, and that check runs before authentication — so a mainnet deployment never even confirms the route is there, whatever key you present. The two gates are deliberately independent: key mode is a convention of key creation, not an invariant a database row is forced to honour.

Simulated payments are labelled. The attempt carries txHash: null — there is no transaction, and a made-up hash would be a lie in a field other systems point a block explorer at. So txHash alone cannot tell you what you are looking at: a pending real attempt has no hash either. That is why every attempt carries a simulated boolean instead — in the webhook payload under data.attempt.simulated and on GET /api/v1/links/:publicId under attempts[].simulated. It is always present and false for real payments, so a handler can branch on it and analytics can exclude simulated revenue without guessing from the payer wallet.

Agent API (pp_ag_)

POST/api/v1/agent/pay

Pay an x402 URL from the agent account.

Request
{
  "url": "https://api.example.com/report",  // required
  "maxAmountUsdc": "1.00",                  // optional ceiling for this call
  "description": "optional, <= 500 chars"
}
Response
200 { "paymentId", "status": "settled", "txHash",
  "response": { "status", "headers", "bodyBase64", "mimeType" }, "payment" }
202 { "paymentId", "status": "pending_approval", "approvalUrl", "payment" }
400 invalid_url | invalid body
403 { "error", "code": "policy_blocked" }   // allowlist miss, hard block
422 amount_exceeds_max | payment_not_required | unsupported_payment_requirement
502 target_unreachable | { "status": "failed", "error", "payment" }

GenesisPay fetches the URL, decodes the x402 V2 PAYMENT-REQUIRED (exact scheme, USDC on the configured chain), evaluates your spending policy, signs EIP-3009 with the account wallet, and retries the target with PAYMENT-SIGNATURE.

GET/api/v1/agent/payments/:id

Poll a payment's status.

Response
200 { "payment": { "id", "status", "amountUsdcMinor",
  "resourceUrl", "destinationWallet", "txHash", "failureReason",
  "approvalExpiresAt", "resolvedAt", "settledAt", ... } }
404 { "error": "Agent payment not found." }

Statuses: pending_approval, approved, denied, executing, settled, failed, expired. Overdue pending approvals flip to expired on read (default expiry 24h).

POST/api/v1/agent/payments/:id/execute

Execute an approved payment.

Response
200 same shape as /agent/pay when settled
404 not_found
409 not_approved | approval_expired | already_executing
502 { "status": "failed", "error", "payment" }

Idempotent. Approval itself happens only on the website; approving from the dashboard already executes the payment server-side, so agents usually just poll.

GET/api/v1/agent/account

Read the account, policy, and spend totals.

Response
200 { "name", "walletAddress", "chainId", "status",
  "usdcBalance",                       // minor units string, null if RPC fails
  "policy": { "perPaymentCapUsdcMinor", "dailyCapUsdcMinor",
              "monthlyCapUsdcMinor", "allowlistEnabled" },
  "spentTodayUsdcMinor", "spentThisMonthUsdcMinor" }

Caps are integer USDC minor-unit strings; null means no cap.

Discovery (public)

GET/api/v1/discovery

Search the public service directory. No auth required.

Request
?q=flight            // optional substring search (title/description/category)
&category=flights    // optional exact category filter
&limit=20            // optional, 1..50 (default 20)
Response
200 { "listings": [{ "title", "description", "priceUsdc",
  "kind": "api" | "link", "resourceUrl", "category" }] }
400 { "error", "issues" }
429 { "error", "code": "rate_limited" }

Free and unauthenticated, rate-limited per client IP. priceUsdc is a decimal string; pay resourceUrl via the agent API.

The payment endpoint itself

Every link is payable directly over HTTP without any GenesisPay key: GET /pay/:linkId with JSON or agent headers returns 402 Payment Required with an x402 V2 PAYMENT-REQUIRED header. Retry with a PAYMENT-SIGNATURE header — either a signed EIP-3009 authorization (GenesisPay settles it on-chain when a facilitator key is configured) or a signature plus a broadcast txHash extension. Browsers hitting the same URL get the hosted checkout.

Webhook signatures are documented on the webhooks page.