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. Deployments using shared admission may return 503 with rate_limit_unavailable and a Retry-After header before new work. This is not a payment failure; preserve the original purchase key when retrying. Retained payment recovery remains independent of the shared admission store.
Beta: creating new mandate authority is locked. Mandate proposal and activation, new mandate-backed charges and agent purchases, subscription renewals and POST /api/v1/plans answer 409mandate_authority_locked_for_beta, with no Retry-After. Signed submission, revocation, reads, reconciliation, one-off checkout and agent x402 pay-per-use are unaffected.
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. A strict idempotent single-use WooCommerce request may use taxConfig version 2 to freeze exact subtotalMinor, taxMinor and totalMinor with typed lineItems; GenesisPay never invents an aggregate rate. metadata, clientReferenceId, returnUrl, and cancelUrl are stored untouched and echoed by every read; unset fields come back as null. See the field limits below. A new link below the minimum price ($0.01) is refused with amount_below_minimum; an idempotent replay of an older link still returns it.
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, no errorReason)
202 { "version": 1, "planId", "state": "queued", "acceptedAt", "expiresAt", "pollAfterMs", "statusUrl" }
402 PAYMENT-RESPONSE failure (verification or settlement failed, nothing moved)
409 { "error", "code": "settlement_plan_mismatch" } when the request differs from the prepared plan (payTo included)
426 { "error", "code": "settlement_plan_required" } with no planId, or a planId another key prepared
no planId + a payer-broadcast txHash: still 426 (it may already be on-chain: do not request another payment);
a planId + txHash that fails either plan check: the 503 outcome-unknown below, not 409/426
503 PAYMENT-RESPONSE outcome unknown (carries the transaction hash when known; do not pay again)
503 { "error", "code": "service_unavailable" } when no facilitator key is configured,
or + Retry-After when the database is briefly unavailable (resubmit the same signed payment)
500 { "error", "code": "internal_error" } on an unexpected server fault
Used by @genesis-tech/genesispay-seller's genesisPaySettlement helper after preparing a settlement plan (POST /api/v1/facilitator/settlement-plans); planId is required, and the plan binds payTo, amount and resource to the seller key that prepared it. 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. If the payer already broadcast the authorization, submit its transaction hash with the same plan and signature. GenesisPay confirms an exact verified transfer even when no relay job was accepted, including queue-bound plans. Existing queued/submitted work remains pending until its worker reconciles it; terminal failed/expired jobs require separate reconciliation. A 503 carrying a hash never means pay again. Settlement is idempotent per authorization nonce.
Invoice line unit prices have an inclusive 1,000,000-token ceiling. Line totals, subtotal and final total must fit 9,223,372,036,854,775,807 minor units (PostgreSQL int8). Invalid amounts return 422 invalid_request. 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/invoices/summaries?limit=25&after=...
Read a bounded page of owned invoice summaries, newest first.
Limit defaults to 25 and must be an integer from 1 to 100. Omit after on the first request; subsequent requests pass the opaque nextCursor unchanged. Invalid limits/cursors return 422. Ordering is creation time then ID descending, with exact timestamp precision. Summaries omit nested lines/deliveries and use immutable finalized customer names. GET /api/v1/invoices retains its newest 100 full invoice responses; GET /api/v1/invoices/:publicId reads full detail.
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, capped at 1,000,000 tokens. Line totals, subtotal and final total must fit PostgreSQL int8 (9,223,372,036,854,775,807 minor units); malformed or oversized amounts return 422 invalid_request. 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. A total below the minimum price ($0.01) answers 422 amount_below_minimum with field "lineItems" on create, on a PATCH that changes the total, and on finalize.
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. retainUntil is the end of the retention period: 7 years after the end of the business year, read as the Europe/Vienna calendar year of issuedAt (the end of 31 Dec of that year + 7 in Vienna, e.g. 2033-12-31T22:59:59.999Z).
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.
lineItems
Optional immutable payer-facing summary with 1–50 entries. Each entry requires name (1–200), quantity (integer 1–9999) and a non-negative decimal line-total amount with up to 6 decimals; optional description (500), SKU (100), http(s) imageUrl (2048), and kind (item, shipping, fee or tax). Exact line totals must add up to the checkout amount. kind is required on every line for version-2 external WooCommerce tax.
taxConfig
Version 1 is the explicit item treatment/rate assertion. Strict idempotent single-use WooCommerce orders may use { version: 2, calculation: "external", source: "woocommerce", subtotalMinor, taxMinor, totalMinor }; all three are canonical integer minor-unit strings, subtotal + tax must equal the payment amount, and typed line sums must match. External assertions are immutable.
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.
Physical-commerce integrations may also send a canonical HTTPS storefrontUrl. Human Explore can use it to open the exact merchant product page; the merchant checkout still calculates shipping, tax, availability, and the final total. It is never exposed as an x402 payment resource.
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
"storefrontUrl": "https://shop.example/product/report", // optional, human Explore handoff
"sku": "MDR-1", // optional, unique per account, <= 64 chars
"asset": "USDC", // optional, "USDC" (the beta default)
"delivery": { "type": "none" } // required shape when storefrontUrl is present
}
price is echoed back normalized ("2.00" in, "2" out) beside priceMinor, the integer minor units as a string. storefrontUrl must be canonical https without credentials or a fragment; it is navigation for human Explore only, never payment or fulfilment authority, and may be combined only with delivery.type none. 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. A merchant-storefront product refuses GenesisPay fulfilment because the merchant owns delivery, shipping, and tax. 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 directly payable product, enforced by the database — mint again (or concurrently) and you get the same link with created: false. Products carrying storefrontUrl refuse this operation before any legacy link can be returned; create the exact merchant order first, then its single-use request. 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. A prepare that proposes its own authorization nonces returns 409 authorization_nonce_used when one of them was already used on-chain (propose a new nonce), and a retryable 503 or 429 with Retry-After when that check cannot run yet; nothing is prepared in either case.
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. buyer (beside evidence, verified responses only) is the payer's Sign in with GenesisPay sub for your clients, or null for agent payers and buyers without a live sign-in with you; it identifies and never authorizes.
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.
Mandate decimal input (allowance, capPerCharge,amountPerPeriod) is capped at 1,000,000 tokens inclusive, for both legacy and contract mandates; larger inputs return 422 invalid_request. The explicit contract_mandate_v1 protocol supports kind: "per_use" with two payer approvals at activation. Each later payment needs a separate payer signature. These capabilities remain gated and have no Mainnet release approval.
With your seller key, POST /api/v1/mandates/:id/charges/propose with an Idempotency-Key, the protocol, integer-string amountMinor, and optional resourceUrl and description. The response retains the exact payment before returning chargeTypedData for the payer to sign.
POST only the protocol and payer signature to /api/v1/mandates/:id/charges/:chargeId/submit. HTTP202 means pending. GET /api/v1/mandates/:id/charges/:chargeId to check confirmation; fulfill only after verified settlement. Reuse the saved proposal on retries. The legacy /charge route refuses contract mandates because it cannot supply the payer's charge signature.
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": "link_already_paid" } // single-use link already paid,
// incl. a lost concurrent call
409 { "error", "code": "settlement_in_progress" } // link busy; honour Retry-After
409 { "error", "code": "conflict" } // archived, or an invoice link
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.
Save one key per purchase before sending. Identical normalized request terms replay the original payment before network or policy work. maxAmount/maxAmountUsdc are comparison constraints with no amount ceiling; guards above 1,000,000 retain replay compatibility. x402 offers must fit 9,223,372,036,854,775,807 minor units (PostgreSQL int8), otherwise admission returns 422 unsupported_payment_requirement. Changed terms and historical keys fail closed. Replay never signs or fetches fulfillment again and always has response: null. Poll the original payment for nonterminal states. maxAmount and maxAmountUsdc must agree when both supplied. V1 remains for existing integrations and also requires idempotencyKey.
{
"url": "https://api.example.com/report", // required
"idempotencyKey": "saved-order-42", // required; save it before the first send
"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 idempotency_key_required // no idempotencyKey: nothing fetched, written or signed
400 invalid_url | invalid body
403 { "error", "code": "policy_blocked" } // allowlist miss, hard block
409 { "error", "code": "payment_already_requested", "paymentId", "paymentStatus", "payment" } // same key again
422 amount_exceeds_max | payment_not_required | unsupported_payment_requirement
| external_offer_not_pinned // curated external service, live offer outside its pin
| external_service_quarantined // curated service quarantined or retired; nothing sent to it
502 target_unreachable | { "status": "failed", "error", "code": "payment_failed", "payment" }
503 external_registry_unavailable // curated registry unreadable: no agent payment now, nothing sent or signed
Every request needs an idempotencyKey: one saved key per purchase, resent unchanged on every retry. A request without one is refused with 400 idempotency_key_required before anything is fetched, written or signed. New integrations should use /api/v2/agent/pay. 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). resourceUrl is null for the payment of a physical-product purchase: the shop order's own pay link is never returned.
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.
Free and unauthenticated, rate-limited per client IP. priceUsdc is a decimal amount of asset; call resourceUrl with method. Price and method are hints: the resource's 402 is authoritative.
GET/api/v1/discovery/shops
List the listed shops in the public directory. No auth required.
Same listing and eligibility rules and per-IP rate limit as /api/v1/discovery; products only. signal is sales at 3 or more distinct buyers of real paid orders in the window (simulated payments and self-payments never count), otherwise new; sales products order in coarse bands (3–4, 5–9, 10–24, 25–99, 100+), then by listing date. Counts, revenue and buyer data are never returned. The sales signal may be up to 60 s old; listing and eligibility are always current.
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.
GET /api/v1/kyb/purge-expired and POST /api/v1/kyb/purge-expired are GenesisPay operator routes, not seller or agent APIs. They require the private cron secret (MR-605); API keys cannot call them. GET reports the due count. POST runs the five-year KYB cleanup only after operations enables it, and a failed account answers 503 for retry. The period and batch size are fixed by the service.
The protocol package contains experimental helpers for contract-bound mandates and atomic batch payments. The explicit mandate APIs above accept their contract signatures only when the deployment gates allow them. Batch signatures have no public settlement route yet, and no Mainnet contract deployment is approved. A contract authorization cannot be substituted for a direct transfer after signing.