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.
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.
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.
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.
One-off invoices are drafts until explicitly finalized. See the invoicing guide for the lifecycle, tax scope, hosted page, PDF, and Resend delivery model.
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.
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.
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 /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.
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.
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.
Validation happens before anything is stored; a violation returns 422 with an issues array naming the offending path. The same limits apply to the dashboard and to checkout.create() in the SDK.
Field
Limit
title
Required, 1–120 characters (trimmed).
description
Up to 1000 characters. Empty becomes null.
amount / amountUsdc
Required. A positive decimal string with up to 6 decimals ("5.00"), denominated in the request's asset — euros on an EURC link, dollars on a USDC one. amountUsdc is the older name for the same field and still works; sending both is accepted only when they resolve to the same minor units, so "5.0" and "5.00" agree while "5.00" and "50.00" are rejected rather than one of them silently winning. Every response carries both names plus amountUsdcMinor, the integer minor units.
metadata
Flat map of string keys to string values. Max 20 keys; each key 1–40 characters; each value up to 500 characters; max 4096 bytes serialized as JSON. Non-string values (numbers, objects, arrays) are rejected, never coerced. {} is stored as null.
clientReferenceId
Up to 200 characters, trimmed. Empty becomes null.
returnUrl / cancelUrl
Up to 2048 characters and must parse as a URL. The scheme must be https; http is allowed only for localhost and 127.0.0.1. Everything else (javascript:, data:, file:, …) is rejected — these values become links in the payer's browser.
metadata and clientReferenceId also travel on webhook payloads under data.link. The redirect URLs do not — they are only used to build the payer's return link, and the parameters GenesisPay appends to returnUrl are a UI hint, never proof of payment.
The echo is exact: non-empty metadata keys and values round-trip unchanged, an empty object is stored as null, and clientReferenceId is trimmed with a blank value becoming null — on create, retrieve, and the webhook payload alike.
The hosted checkout may carry a cs query parameter: that is GenesisPay's internal payer checkout-session identity, not a merchant correlation field. Correlate with metadata and clientReferenceId instead.
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)
}
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
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.
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.
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.
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.
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.purchasedwebhook 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.
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.
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.
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.
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.
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.
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_.
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.
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.
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
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 parameter
Rules
planId
A 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.
status
One 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.
limit
Integer 1–100, default 25. Accepts a numeric string. Out of range is a 400 raised before the query runs.
startingAfter
The 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) thrownew 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.
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.
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.
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.
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.