API reference

The /api/v1 surface

Authenticated endpoints take Authorization: Bearer <secret> — seller endpoints use gp_sk_ keys, agent endpoints use gp_ag_ keys. Keys are created in the dashboard, shown once, and revocable; GenesisPay stores only SHA-256 hashes. Money is USDC or EURC on Base: decimal strings in requests, integer minor-unit strings (6 decimals) in responses. Errors are JSON { "error", "code" }: code is a stable snake_case identifier and is the field to branch on, while error is human-readable prose that may be reworded at any time. Validation failures add an issues array. Requests over the per-key or per-IP rate limit get 429.

Seller API (gp_sk_)

POST/api/v1/links

Create a single-use payment request. Use Products for repeat sales.

Request
{
  "title": "Market report",          // required, <= 120 chars
  "description": "Optional note",    // optional, <= 1000 chars
  "amount": "5.00",                  // required, decimal string in "asset"
  "asset": "USDC",                   // USDC-only creation during beta
  "destinationWallet": "0x...",      // optional, defaults to your GenesisPay wallet
  "linkType": "single",              // optional; "reusable" is rejected (422)
  "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", "code": "invalid_request", "issues": [{ "path", "message" }] }

amount is a decimal string; amountUsdc is its deprecated alias. Sending both is allowed only when the amounts match. Payment requests close after payment. Explicit reusable creation is rejected with 422; create a Product for repeat sales and separate purchases. 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.", "code": "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
{
  "planId": "<prepared SettlementPlanV1 UUID>",
  "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)
202 { "version": 1, "planId", "state": "queued", "acceptedAt", "expiresAt", "pollAfterMs", "statusUrl" }
402 PAYMENT-RESPONSE failure (verification or settlement failed)
503 { "error", "code": "service_unavailable" } when no facilitator key is configured

Used by @genesis-tech/genesispay-seller's genesisPaySettlement helper after preparing a settlement plan. With the benchmark async queue enabled, the SDK polls the seller-key-protected status URL after a hashless 202 and reports success only after GenesisPay verifies the exact seller transfer and real transaction hash. Settlement is idempotent per authorization nonce.

Invoices and customers (gp_sk_)

One-off invoices are drafts until explicitly finalized. See the invoicing guide for the lifecycle, tax scope, hosted page, PDF, and Resend delivery model.

POST/api/v1/customers

Create a reusable invoice customer.

Request
{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "companyName": "Analytical Engines Ltd",
  "countryCode": "GB",
  "taxId": "optional",
  "metadata": { "crmId": "contact_42" }
}
Response
201 { "customer": { "id", "publicId", "name", "email",
  "companyName", "addressLine1", "addressLine2", "city", "postalCode",
  "countryCode", "taxId", "metadata", "createdAt", "updatedAt",
  "archivedAt" } }

GET /api/v1/customers lists active customers. GET/PATCH /api/v1/customers/:publicId reads or updates one; POST /archive removes it from future lists without changing finalized invoice snapshots. Email is not unique.

POST/api/v1/invoices

Create an editable invoice draft.

Request
{
  "customerId": "cus_...",
  "asset": "USDC" | "EURC",
  "dueAt": "2026-08-31T23:59:59.999Z",
  "discountBps": 500,
  "calculationVersion": 2,
  "memo": "Project reference",
  "footer": "Thank you.",
  "lineItems": [
    { "description": "Consulting", "quantity": 2, "unitAmount": "450.00",
      "taxConfig": { "version": 1, "treatment": "taxable", "rateBps": 2300, "note": null } }
  ]
}
Response
201 { "invoice": { "publicId", "invoiceNumber": null,
  "status": "draft", "asset", "chainId", "customer", "lineItems",
  "subtotalMinor", "discountBps", "discountMinor", "taxBps", "taxMinor",
  "totalMinor", "dueAt", "hostedInvoiceUrl": null, "pdfUrl": null,
  "payment": { "payUrl": null, "txHash": null, "payerWallet": null } } }

unitAmount is an inclusive decimal string with up to 6 decimals. quantity is an integer. Each line requires explicit taxConfig; discount reduces each line before its included tax is split with half-up rounding. GET lists invoices, GET /:publicId retrieves one, and PATCH /:publicId replaces a draft. Legacy v1 drafts preserve their invoice-wide exclusive tax unless explicitly upgraded; finalized invoices never change.

POST/api/v1/invoices/:publicId/finalize

Freeze a draft and open it for payment.

Response
200 { "invoice": { "invoiceNumber": "INV-000001",
  "status": "open", "hostedInvoiceUrl": "https://.../invoice/inv_...",
  "pdfUrl": "https://.../invoice/inv_.../pdf",
  "payment": { "payUrl": "https://.../pay/link_...", ... }, ... } }

Finalization is transactional and idempotent. It snapshots the document, allocates the seller-local number, and creates one single-use payment link. Finalized invoices cannot be edited.

POST/api/v1/invoices/:publicId/send

Email a finalized invoice and its PDF.

Request
Idempotency-Key: invoice-inv_123-initial
Response
200 { "delivery": { "id", "status": "sent",
  "providerMessageId", "sentAt" } }
422 { "code": "missing_idempotency_key", ... }
503 { "code": "invoice_email_not_configured" | "invoice_email_failed", ... }

The Idempotency-Key header is required. The configured Resend sender must be verified. Reusing a successful key returns the existing delivery.

POST/api/v1/invoices/:publicId/void

Stop collecting an unpaid invoice.

Response
200 { "invoice": { "status": "void", "voidedAt": "...", ... } }

POST /mark-uncollectible is the accounting write-off sibling. Both archive the payment link. If a real payment confirmed concurrently, the response projects paid because moved money wins.

Hosted checkout documents (gp_sk_)

Formal invoices and enhanced receipts issued after hosted human checkout are immutable commercial records, not manual invoice drafts. Use genesispay.checkoutDocuments.list() orgenesispay.checkoutDocuments.get(publicId) in the Seller SDK to read the same archive. The seller is the legal issuer and owns the configured tax-inclusive rate; GenesisPay renders and facilitates.

GET/api/v1/checkout-documents

List immutable documents generated by hosted human checkout.

Response
200 { "documents": [{ "publicId", "kind", "invoiceNumber", "asset",
  "chainId", "subtotalMinor", "discountMinor", "taxMinor", "totalMinor",
  "txHash", "confirmedAt", "issuedAt", "retainUntil", "recipientEmail",
  "snapshot" }] }

Returns the newest 100 documents for the authenticated seller. New human checkout requires reviewed issuer details and explicit item tax before authorization, then creates one invoice identity with invoice and payment-receipt PDFs after confirmed real settlement. Historical v1 enhanced_receipt records remain readable. GenesisPay does not determine the seller's rate. This archive is read-only and separate from seller-authored /api/v1/invoices.

GET/api/v1/checkout-documents/:publicId

Retrieve one seller-owned hosted checkout document.

Response
200 { "document": { "publicId", "kind", "invoiceNumber", "totalMinor", "snapshot" } }

Returns private, immutable buyer/seller/tax snapshot data for the authenticated seller only.

GET/api/v1/checkout-documents/:publicId/pdf

Download the immutable checkout document as a verified PDF.

Response
200 application/pdf

The response is private/no-store and carries the PDF renderer version and SHA-256 identity in GENESISPAY response headers.

Products (gp_sk_)

A product is a catalogue entry: name, price, SKU, and — for digital goods — the https fulfilment URL a paying buyer's entitlement redirects to. Its payable instance is one canonical reusable payment link, minted idempotently below. Public ids are prefixed prod_; a product belonging to another account answers 404, never 403.

POST/api/v1/products

Create a catalogue product.

Request
{
  "name": "Market data report",      // required, <= 120 chars
  "price": "2.00",                   // required, decimal string — never a float
  "description": "Daily PDF",        // optional, <= 1000 chars
  "imageUrl": "https://...",         // optional, rendered only, never fetched
  "sku": "MDR-1",                    // optional, unique per account, <= 64 chars
  "asset": "USDC"                    // optional, "USDC" (the beta default)
}
Response
201 { "product": { "publicId": "prod_...", "name", "description", "imageUrl",
  "sku", "asset", "price", "priceMinor", "archived": false,
  "fulfilmentUrl": null, "fulfilmentVerifiedAt": null, "createdAt" } }
409 { "error", "code": "duplicate_sku" }
422 { "error", "code": "invalid_request" }

price is echoed back normalized ("2.00" in, "2" out) beside priceMinor, the integer minor units as a string. A repeated sku is refused, not silently duplicated — the SKU is your key, so a re-imported catalogue stays reasoned about.

GET/api/v1/products

List your products.

Request
?includeArchived=true   // optional, default false
Response
200 { "products": [ { ...product } ] }

Scoped to the seller key's account. Archived products are excluded unless asked for.

PATCH/api/v1/products/:publicId

Set or clear the fulfilment URL.

Request
{ "fulfilmentUrl": "https://your-site.example/download" }  // or null to clear
Response
200 { "product": { ...product } }
404 { "error": "Product not found.", "code": "not_found" }
422 { "error", "code": "invalid_request" }

https only — a paying buyer's entitlement redirects here. Changing the URL resets fulfilmentVerifiedAt: a verification is a statement about the URL it verified. This is the only product edit; price and name are fixed at creation (a link copies them at mint anyway). GET reads one product with the same 404 behaviour.

POST/api/v1/products/:publicId/archive

Archive a product.

Response
200 { "product": { ...product, "archived": true } }
404 { "error": "Product not found.", "code": "not_found" }

Archiving stops NEW payment-link mints; the product's existing canonical link stays payable — a buyer mid-checkout is not punished for a catalogue edit. Archiving twice is a no-op that returns the same product.

POST/api/v1/products/:publicId/payment-link

Mint the product's canonical payment link.

Response
201 { "link": { ...link, "payUrl": "https://.../pay/..." }, "created": true }
200 { "link": { ...link }, "created": false }   // the link already existed
404 { "error": "Product not found.", "code": "not_found" }
409 { "error", "code": "product_archived" | "no_wallet" }

One reusable link per product, enforced by the database — mint again (or concurrently) and you get the same link with created: false. The product's price and asset are copied onto the link at mint; a later catalogue edit never changes what a buyer already sees. ...link is the same shape POST /api/v1/links returns. Never hardcode link.payUrl: the embedded inv_ id changes when the link is archived and reminted — use products.permalink(publicId), or resolve link.payUrl at render time.

GET/api/v1/products/:publicId/contract

Read the current strict product contract for readiness checks.

Request
GENESISPAY-Version: 2026-08-26
Response
200 { "contract": { "object": "product_contract", "productId", "sku",
  "grossAmountMinor", "network", "settlementDestination", "delivery" },
  "requestId": "req_...", "apiVersion": "2026-08-26" }

This is the current purchasable contract only. It is never used to reconstruct an older checkout. Every response carries matching GENESISPAY-Version and GENESISPAY-Request-Id headers plus Cache-Control: no-store.

POST/api/v1/products/:publicId/checkouts

Create one strict product-backed single-use payment link.

Request
GENESISPAY-Version: 2026-08-26
Idempotency-Key: order_123

{ "expected": { ...immutableProductContract }, "clientReferenceId": "order_123",
  "metadata": null, "returnUrl": "https://shop.example/thanks", "cancelUrl": null }
Response
201 { "object": "product_checkout_link", "linkId": "inv_...", "payUrl": "https://.../pay/inv_...",
  "productContractVersion": "2026-08-26", "created": true,
  "requestId": "req_...", "apiVersion": "2026-08-26" }
200 { ...same link, "created": false }   // exact idempotent retry
409 { "error", "code": "contract_mismatch" | "idempotency_conflict" }

The identity is explicitly linkId—there is no seller-facing checkout.id. The transaction compares and freezes product ID, nullable SKU, delivery, amount, asset deployment and destination. Gate products use their x402 resource and are refused here.

The permanent product URL. Everything above is keyed to your seller key; this one route is public, and it is the URL to put in a buy button:

GET/pay/p/:productPublicId

The permanent product checkout URL (public — no key).

Response
307 → /pay/:linkId     // the product's CURRENT canonical link, query preserved
404 { "error", "code": "not_found" }  // unknown, archived, or no live link (browsers: human page)
503 { "error", "code": "service_unavailable" }

The one URL to hardcode per product: prod_ never changes, and the inv_ id it resolves to is looked up fresh on every request, so it survives any link remint. Read-only — a public GET never mints. Every response is Cache-Control: no-store. The SDK builds it with products.permalink(publicId): no network call, no mint.

Selling a product end to end: create it, PATCH the fulfilment URL, and share products.permalink(product.publicId) (or link.payUrl resolved at render time — never hardcoded). A confirmed purchase fires the product.purchased webhook carrying the buyer's entitlement (entitlement.redemptionPath — a signed, expiring redirect to your fulfilment URL). Test the whole leg with POST /api/v1/links/:publicId/simulate-payment — the entitlement arrives marked simulated: true, and so does the webhook.

Strict fulfillment authority (gp_sk_)

Tolerant checkout, entitlement, webhook and browser-return objects are useful locators and display state, but they do not authorize delivery. Seller SDK 1.0 calls this separate versioned endpoint and invokes your code only after every expected authority field matches.

Fee terms describe what was quoted or authorized; fee collection is a separate outcome. A record_only quote may equal gross while the seller still received the full payment. Quotes above gross are invalid; a payer_authorized fee must leave a positive seller amount.

Strict direct/product checkout link creation and product-gate calls return 409 seller_mode_mismatch when the key mode does not match the payment chain. Recovery reports settlement_outcome_unknown when settlement identity or confirmation is unproven. Interrupted response streams are retryable; completed malformed evidence requires operator attention. Neither recovery outcome authorizes fulfillment.

An already-confirmed gate payment can recover after its original authorization expires. Retry the same signed request; GenesisPay checks the original payment and does not broadcast or collect a fee again. Unpaid expired authorizations are still rejected, and terminal attempts return 409 attempt_failed or 409 attempt_expired. A retry signed by a different payer returns 409 payer_mismatch once the attempt is bound, including while settlement is submitted. Retry the complete original signed payload: reserved fee authorizations cannot be added or removed. A collecting attempt without its fee leg returns 409 fee_authorization_required before broadcast.

POST/api/v1/fulfillment/verify

Verify immutable seller-scoped payment authority.

Request
GENESISPAY-Version: 2026-08-26

{ "locator": { "attemptId": "..." },
  "expected": { ...immutableProductOrLinkContract } }
Response
200 { "object": "fulfillment_verification", "verified": true,
  "evidence": { ...immutableAuthoritySnapshot },
  "requestId": "req_...", "apiVersion": "2026-08-26" }
200 { "object": "fulfillment_verification", "verified": false,
  "reason": "not_found" | "not_confirmed" | "simulated" | "entitlement_invalid",
  "requestId": "req_...", "apiVersion": "2026-08-26" }
409/422 { "error", "code", "requestId", "apiVersion" }

Supply exactly one attemptId, linkId or entitlementId. The seller key supplies seller scope. Missing, null, malformed, historical, contradictory or contract-mismatched evidence fails closed; caller-created evidence objects are never accepted. Money is a bounded decimal string on the wire and bigint in seller SDK 1.0.

GET/api/v1/fulfillment/attempts

Discover confirmed attempts for recovery; this is not payment authority.

Request
GENESISPAY-Version: 2026-08-26
?clientReferenceId=pint_123&confirmedAfter=2026-08-28T00:00:00Z&limit=50
Response
200 { "object": "fulfillment_attempt_list", "data": [{ "attemptId": "uuid",
  "linkId": "inv_...", "linkType": "single", "clientReferenceId": "pint_123",
  "confirmedAt": "2026-08-28T12:00:00.000123Z", "authorityVersion": "2026-08-26" }],
  "nextCursor": null, "requestId": "req_...", "apiVersion": "2026-08-26" }
400 { "code": "invalid_request" | "invalid_cursor", ... }

Requires a seller secret key; both seller and live/test chain scope are enforced. Filters are optional: exact clientReferenceId (max200), inclusive confirmedAfter/confirmedBefore ISO timestamps, limit1..100(default50), and opaque cursor. Keep filters unchanged between pages. Cursor binds seller, mode and a fixed upper bound; null nextCursor completes a pass. Microseconds are preserved. Repeat scans for late commits. Simulations are excluded; historical authorityVersion:null may be returned for diagnostics. Verify every candidate against your stored expected contract before fulfillment.

POST/api/v1/checkout/reconcile-submitted

Internal receipt-only recovery of submitted payments.

Response
200 { "processed": 5, "confirmed": 4, "unresolved": 1, "skipped": 0 }

Cron-secret authentication only. A separate minute service verifies persisted transaction hashes and atomically confirms and enqueues events. It never signs, broadcasts or collects fees. Checkout transfer freshness and x402 authorization nonce/fence checks remain enforced. Missing or contradictory evidence stays submitted for retry/operator attention; terminal payments never reopen. This endpoint is not seller fulfillment authority.

POST/api/v1/webhooks/run-deliveries

Internal durable outbox publication and delivery tick.

Response
200 { "published": 100, "deliveries": 20 }

Cron-secret authentication only, not a seller API. A separate minute worker runs scripts/run-deliveries.mjs. It publishes bounded outbox pages then claims due deliveries with leases. No payment settlement or fee broadcast is performed.

POST/api/v1/webhooks/reconcile-fulfillment

Internal recovery of confirmed-payment notifications and URL entitlements.

Response
200 { "processed": 100, "eventsEnqueued": 0, "entitlementsRepaired": 0, "passCompleted": false }

Cron-secret authentication only. Scans persisted real strict confirmations since the migration cutover using a durable cursor. Repairs missing events/entitlements without chain or browser I/O. Never reopens a dead letter and never changes payment state. The minute runner calls this separately after delivery; existing money-sweep cadence is unchanged.

Subscription plans (gp_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 gp_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 GenesisPay 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", "code": "invalid_request", "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 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.", "code": "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.", "code": "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 (gp_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", "code": "invalid_request", "issues": [{ "path", "message" }] }
400 { "error": "startingAfter is not a mandate of this account.", "code": "invalid_cursor" }

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, cancelled. Cancelled is terminal unbroadcast authority and requires a fresh payer action. 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.GENESISPAY_BASE_URL!;
const headers = { authorization: `Bearer ${process.env.GENESISPAY_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.", "code": "invalid_json" }
403 { "error", "code": "live_key_not_allowed" }   // live key
404 { "error": "Not found.", "code": "not_found" }   // mainnet deployment
404 { "error": "Payment link not found.", "code": "not_found" }  // not your link
409 { "error", "code": "conflict" }   // already paid, or archived
422 { "error", "code": "invalid_request", "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 gp_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 (gp_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", "code": "payment_failed", "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.", "code": "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" }] }
422 { "error", "code": "invalid_request", "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.