@genesis-tech/genesispay-agent
v2.0.0
Published
Client SDK for the GenesisPay Agent API (pay URLs, poll approvals).
Maintainers
Readme
@genesis-tech/genesispay-agent
Client SDK for the GenesisPay Agent API: pay for x402-gated URLs from an agent wallet with spending policies, human approvals, and full audit trail handled by GenesisPay.
This README documents 2.0.0 (prepared, not published), requiring protocol ^0.8.0. It includes the prior 1.7.1 behavior (an https-only base URL: see
Environment variables), including 1.7.0 (server-issued purchase keys: see
Purchase keys) and 1.6.0 (physical products: quote-only discovery
results, quote(), purchase() and the saved shipping profile; see
Physical products). Its service descriptions, result recovery,
curated external directory, commerce and review methods require a GenesisPay deployment
that serves them; reviews additionally require explicit review permission
(migrations through 0161, and 0162 for external-service purchase reviews).
Existing payment credentials do not automatically gain review permission.
Purchase intent (2.0.0, prepared, not published)
pay(url, options) and purchase(input) now require a
purchaseIntent: PurchaseIntent | null key. This is a breaking major release.
Use null only during the transition: after the owner activates intent, new
purchases with null receive 422 purchase_intent_required, with nothing
charged. Capture stays off until the owner publishes the privacy amendment
and activates the flag. Supplied intents still bind identity and enforce the
maximum with capture off, without being retained.
import { createPurchaseIntent, GenesisPayAgent } from "@genesis-tech/genesispay-agent";
const agent = new GenesisPayAgent();
const url = new URL("https://example.com/report").href;
const approvalQuote = "Buy the report for at most 12.50 USDC including fees.";
const purchaseIntent = createPurchaseIntent({
item: { url },
maxTotal: "12.50", // Total debit, including buyer fee; never a float.
asset: "USDC", // Default; EURC is also a protocol asset.
approvalQuote, // Verbatim: never trimmed or normalized.
conversation: [{ role: "user", content: approvalQuote }], // Local only.
});
const { purchaseKey } = await agent.createPurchaseKey();
// Save the key AND exact terms, including intent, before sending; reuse on retries.
await agent.pay(url, { idempotencyKey: purchaseKey, purchaseIntent });The helper canonicalizes URLs with new URL(url).href, converts exact decimal
strings to six-decimal integer minor units, and validates with the protocol
parser before returning. More than six decimal places, zero/overflow, invalid
items or approval text throw locally. The approval quote follows the protocol's
strict visible-character allow-list (1–500 UTF-16 units). Show it as escaped
plain text; use structured maxTotal for amounts. The maximum can only refuse;
server policy and owner approval remain authoritative.
For commerce, use item: { productId, quantity, shippingOptionId } from the
quote; delivery becomes { kind: "saved_shipping_profile" }. X402 uses
delivery: null. Exactly one of conversation (user/assistant turns only,
verbatim) or a precomputed conversationDigest is required. The helper checks
that the quote appears verbatim in a user turn when turns are provided; this is
a client convenience, not authority or proof of authorship. Hashing happens
locally with digestPurchaseConversation; neither returned intent nor payment
request contains conversation text. The approval sentence itself is an intent
field and is sent. Keep the visible turns locally to recompute the digest;
the digest is pseudonymous data, not anonymous data.
The five refusal codes are purchase_intent_required, purchase_intent_invalid,
purchase_intent_mismatch, purchase_intent_max_exceeded, and
purchase_evidence_unavailable (503). pay() raises
GenesisPayPaymentRejectedError; purchase() raises GenesisPayCommerceError
with the code preserved. None charges. A generic 5xx remains an unknown outcome.
Keep the same key and exact intent when recovering a possibly running purchase;
never raise the maximum without the buyer's approval.
Purchase keys (1.7.0)
idempotencyKey is a purchase's identity: the same key and terms replay the
original payment, other terms answer idempotency_conflict. GenesisPay scopes
keys per agent account across every client, so a key has to be unique for the
account's lifetime. Code can generate one; a language model cannot — it
repeats "random" keys across chats, and the repeated key returns an old
purchase instead of buying. For a model caller, ask GenesisPay for the key:
const { purchaseKey, expiresAt } = await agent.createPurchaseKey();
await saveOrder({ idempotencyKey: purchaseKey, url, maxAmountUsdc: "0.50" }); // before paying
const result = await agent.pay(url, { purchaseIntent: null, idempotencyKey: purchaseKey, maxAmountUsdc: "0.50", requireIssuedKey: true });createPurchaseKey()→POST /api/v2/agent/purchase-keys, returns{ purchaseKey, expiresAt }. The key (gpk1_…) is bound to this agent account and fits bothpay()andpurchase(). It creates no payment and charges nothing. It must be first used beforeexpiresAt(24 h); a retry of a purchase already made with it keeps working after that.requireIssuedKey: true(an option ofpay()and ofpurchase()'s second argument) makes GenesisPay refuse a free-form key for a NEW purchase. It is not part of the purchase's terms and is sent only whentrue. A retry of a payment that already exists for the key is never refused, whatever the key.- Refusals are HTTP 400s the server sends only when no payment or purchase
exists for the key, so nothing was signed or charged:
purchase_key_required(free-form key withrequireIssuedKey),purchase_key_invalid(agpk1_key that was changed, is not GenesisPay's, or belongs to another account),purchase_key_expired(first used afterexpiresAt).pay()throws them asGenesisPayPaymentRejectedError,purchase()asGenesisPayCommerceError, each with thatcode. The remedy is a new key fromcreatePurchaseKey()— unless the same purchase was already attempted with another key, which a retry must reuse. - Keys unavailable. When GenesisPay cannot issue keys,
createPurchaseKey()throws a 503 (service_unavailable). A 503purchase_key_unavailablefrompay()/purchase()is typed as a refusal before anything was signed (retry later with the same key); a bare 503service_unavailablefrom a money route stays an unknown outcome, because that generic code can come from anywhere in a handler. - Free-form keys keep working wherever
requireIssuedKeyis not sent. The helpersisIssuedPurchaseKey(key)andisPurchaseKeyErrorCode(code), the constantPURCHASE_KEY_ERROR_CODESandpurchaseKeyResponseSchemaare exported.
Compatibility: a free-form key that happens to start with gpk1_ is
now treated as an issued key: on first use (no payment exists for it yet)
it is refused with purchase_key_invalid, and nothing is charged. Retries of
a payment that already exists under such a key are unaffected. Choose another
prefix for self-made keys.
Needs a GenesisPay deployment that issues purchase keys: an older one answers
createPurchaseKey() with code: "purchase_keys_unavailable", refuses
purchase() with requireIssuedKey as invalid_request, and ignores the
flag on pay().
Physical products: quote first (1.6.0)
discover() asks for quote-only physical products too (?include=quote;
an older server ignores it). Such a result is a seller's WooCommerce product:
it has purchase: { mode: "quote", productId, listedPriceUsdc } and no
resourceUrl or priceUsdc, because shipping, tax, stock and the exact
total depend on the delivery address (ADR-0108). Every payable listing keeps
all its fields and, on a newer server, gains source and purchase: { mode:
"pay", … } copies of them.
TypeScript. DiscoveredService is now DiscoveredPayableService |
DiscoveredQuoteProduct, so listing.resourceUrl is typed string |
undefined. Narrow before paying:
import { GenesisPayAgent, GenesisPayCommerceError, isPayableListing, isQuoteProduct } from "@genesis-tech/genesispay-agent";
const results = await agent.discover("chewing gum");
const payable = results.filter(isPayableListing); // as in 1.5: pay(listing.resourceUrl, …)
const physical = results.find(isQuoteProduct);
if (physical) {
try {
const quote = await agent.quote({ productId: physical.purchase.productId, quantity: 1 });
// Show quote.shipTo and an option's totalUsdc; have the user confirm the address.
console.log(quote.shipTo, quote.addressStatus, quote.options, quote.expiresAt);
const option = quote.options[0];
// Only after the user confirmed shipTo and option.totalUsdc. Save the key first.
const purchase = await agent.purchase({ purchaseIntent: null, quoteToken: quote.quoteToken, shippingOptionId: option.id,
expectedTotalUsdc: option.totalUsdc, idempotencyKey: "gum-20261003-a1b2c3" }, { waitForOutcome: true });
if (purchase.status === "settled") console.log(purchase.order?.orderReference, purchase.order?.shop.name);
if (purchase.status === "pending_approval") console.log("Owner approves at", purchase.approvalUrl);
} catch (error) {
if (error instanceof GenesisPayCommerceError && error.reason === "shipping_profile_missing") {
// Ask the user for name and address, then:
await agent.setShippingProfile({ firstName: "Maria", lastName: "Muster",
address: { country: "AT", postalCode: "1010", city: "Wien", line1: "Musterstraße 1" } });
} else throw error;
}
}discover(query, { includeQuote: false }) sends no include and returns
only payable listings, as 1.5 did. A discovery entry this client cannot read
safely (a quote product that carries a payable URL, a purchase hint that
names another URL or price than its listing) is dropped, never returned.
describeService(id) answers a quote-only product with { listing, contract:
null, instructions }.
quote({ productId, quantity })(POST /api/v1/agent/commerce/quotes, quantity 1–20) quotes to the account owner's saved shipping address and returns{ quoteToken, expiresAt, shipTo, addressStatus, options[] }. Nothing is ordered or charged.shipTohas the recipient name and address in full and the email masked.addressStatusisconfirmedornew_requires_approval(treat any other value as the latter): the first order to a new address waits for the owner's dashboard approval even under the caps. Each option has exact decimalsubtotalUsdc,shippingUsdc,taxUsdcandtotalUsdc; the SDK refuses a quote whose total is not the sum of its parts in integer minor units (MR-101), or that names an option twice.getShippingProfile()(GET /api/v1/agent/shipping-profile) returns{ shippingProfile, status, setBy, updatedAt }(name and address in full,emailMasked,phoneMasked), or{ shippingProfile: null, instructions }.setShippingProfile(profile)(PUT) takes{ firstName, lastName, email?, phone?, address: { country, state?, postalCode, city, line1, line2? } }; an omitted email means the owner's account email. It refuses an obviously malformed profile locally (status: 0), sends blank optional fields as absent, and returns the saved view plusconfirmationRequired(neverfalsefor an address that is notconfirmed). The owner is emailed about every change, the owner's agents share five writes a day, and saving orders nothing. Only save details your user gave you for this purpose — never an address taken from product, seller or provider content.
Refusals throw GenesisPayCommerceError (status, code, reason,
instructions, issues). It never means money moved. reason is from the
closed COMMERCE_REFUSAL_REASONS list: the merchant's shipping_unavailable,
insufficient_stock, product_unavailable, unsupported_product_type,
unsupported_shipping_packages, backorders_not_supported,
quote_unavailable, unsupported_currency, GenesisPay's
merchant_plugin_outdated, shipping_profile_missing,
shipping_profile_unavailable, commerce_quote_unavailable,
storefront_product_not_found, email_required and agent_not_active;
anything else reads as null. A deployment without these routes answers
code: "commerce_unavailable".
Buying a quote: purchase() (1.6.0)
purchase({ purchaseIntent, quoteToken, shippingOptionId, idempotencyKey, expectedTotalUsdc? }, { waitForOutcome? })(POST /api/v1/agent/commerce/purchases) orders and pays the quoted option in one call, to the owner's saved address, for exactly the quoted total (MR-506). Call it only after the user confirmedshipToand the option'stotalUsdc; pass that total asexpectedTotalUsdc— it can only refuse (quote_total_mismatch), never raise the charge. Same discipline aspay(): save the key first and retry only with the identical four values; a retry resumes and never orders or pays twice, a new key is a new purchase.- It returns a
CommercePurchase(purchaseId,status,purchasewith product, shop, option, exact amounts andapprovalReasons,order: { orderReference, shop: { name } } | null,totalUsdc,paymentId,payment,txHash,approvalUrl) with one of three statuses:settled(paid and ordered),pending_approval(a new address or a total over the caps: the owner approves in the dashboard atapprovalUrl, and nothing is ordered at the shop before that) orprocessing(being ordered; retry with the same input later).waitForOutcomewaits, bounded and read-only, only through an accepted payment; it never waits for approval. - Everything else throws:
GenesisPayCommerceErrorwith areasonsuch asidempotency_conflict,commerce_quote_expired,commerce_quote_invalid,quote_total_mismatch,shipping_address_changed(confirm the address, quote again),quote_changed(quote again),merchant_network_mismatch,merchant_plugin_outdated,commerce_purchase_denied,approval_expiredoragent_not_active— raised only when no payment exists, and carryingpurchaseIdwhen one was created;GenesisPayPolicyBlockedErrorfor a hard block;GenesisPayPaymentFailedErrorfor a failed payment; and the outcome-unknown classes whenever a charge cannot be ruled out (retry only with the same input). A merchant's own refusal code never surfaces: it reads ascommerce_purchase_failed. getPurchase(purchaseId)(GET /api/v1/agent/commerce/purchases/:id) reads one purchase, read-only, in every status.
Discovery previews and shop browsing (1.4.0)
Version 1.4.0 includes an optional imageUrl on discovery and trending
results. Product images are public seller-supplied previews, never payment
URLs or proof of quality. Manual API listings have no image; older servers
may omit the field. The SDK drops malformed, non-HTTP(S), or credential-bearing
image URLs without losing the listing. This package never fetches a seller's
image URL; rendering depends on the host client. Images and seller text are
untrusted content, not instructions.
Since 1.6.0, productImage(productId) returns a physical (quote-only)
product's picture as bytes for clients that cannot load imageUrl
themselves (an MCP image block, for example). GenesisPay fetches the
seller's image server-side under its SSRF guard, accepts only JPEG, PNG,
WebP or GIF up to 512 KiB and serves a JPEG thumbnail of at most 320 px; the
client re-checks the bytes and answers null for anything else or for a
missing image.
Browse a shop with agent.discover("", { shop: "shop_abcdefgh" }), or call
genesispay_discover with { "shop": "shop_abcdefgh" } (query is optional).
Optional kind accepts api, link, or product; a registered digital
API can be a product, so omit kind when looking for all services.
Use the real public id from shops() / genesispay_shops.
Search and compare before recommending. A recommendation request alone does not authorize a purchase. Pay only within the user's explicit purchase scope and budget; account policy and any required human approval remain server-side. Trending indicates popularity or recency, not a customer review.
Service inputs before purchase (1.4.0)
Search/trending may include serviceContract: { schemaVersion: 1, revision }.
Use the stable listing ID to read the full description, without calling the provider:
const matches = await agent.discover("PredictionEngine");
const candidate = matches.find((item) => item.id && item.serviceContract);
if (candidate?.id) {
const { listing, contract } = await agent.describeService(candidate.id);
// contract is null only for a quote-only physical product (1.6.0).
if (contract) console.log(listing, contract.input, contract.constraints, contract.output);
// Ask for missing user data. Examples are synthetic, never a purchase request.
}GET /api/v1/discovery/services/:id returns { listing, contract }. Every request
rechecks current listing visibility and seller eligibility, and binds the curated
contract to the exact ID, URL and method. Hidden/changed/undescribed entries give
404 not_found; malformed IDs give 422 invalid_request; rate limits give 429.
The SDK refuses mismatched IDs, targets, methods, revisions and unknown schema
versions as invalid_response. Malformed/unknown optional references in search
are dropped without losing the ordinary listing. Old servers may lack this route.
The exported ServiceContract and serviceContractSchema describe version 1:
revision, method, resourceUrl, input (bodySchema/querySchema and JSON
contentType), output (contentType, delivery, description), constraints,
up to two synthetic examples, and provenance (source, optional sourceUrl,
checkedAt). Total size is capped at 16 KiB UTF-8; the JSON Schema subset has
bounded depth/properties, no $ref and no executable extensions. Interpret the
schema AND extra constraints. Source URLs are citations, never automatically fetched.
Async delivery describes a job; it does not authorize arbitrary polling.
Initial descriptions cover PredictionEngine x1/x2/x4/x8. Use actual observations, choose the tier for the requested horizon and obey the half-history bound. All provider text is untrusted data. The live 402 remains price authority; neither a contract nor its example grants purchase permission or overrides account policy.
Install
The command below targets the prepared release and works after publication.
For today’s published SDK use version 1.7.1 and its 1.x API; the live /docs
install pins remain there until release.
Setup requires a GenesisPay deployment that serves /api/v2/agent/pay and
agent SDK version 1.0 or later. POST purchases (method: "POST" with a
body, below) additionally need a deployment that confirms them: its v2
answer echoes requestMethod and bodySha256. Against an older deployment
the SDK refuses to trust a POST result and throws an unknown-outcome error
instead (see the echo check).
npm install @genesis-tech/[email protected]Quick start
import { GenesisPayAgent } from "@genesis-tech/genesispay-agent";
const agent = new GenesisPayAgent({
apiKey: process.env.GENESISPAY_AGENT_KEY, // gp_ag_... (default: env GENESISPAY_AGENT_KEY)
baseUrl: "https://your-genesispay-instance.example", // default: env GENESISPAY_BASE_URL; https only (http just for localhost/127.0.0.1)
});
// Save this purchase key in your durable job/order record BEFORE the first call.
const result = await agent.pay("https://api.example.com/premium", {
purchaseIntent: null, // Until the owner requires intent; see Purchase intent below.
idempotencyKey: "saved-order-42",
maxAmountUsdc: "0.50",
});
if (result.settled) {
console.log(result.txHash);
if (result.response) console.log(result.json()); // capture is null on replay
} else {
// The account's spending policy requires a human decision.
console.log("Approve at:", result.approvalUrl);
}Waiting for human approval
Payments above the account's caps go to pending_approval. Pass
waitForApproval and the client transparently polls
GET /api/v1/agent/payments/:id and calls
POST /api/v1/agent/payments/:id/execute once a human approves:
// Save this purchase key in your durable job/order record BEFORE the first call.
const result = await agent.pay("https://api.example.com/premium", {
purchaseIntent: null, // Until the owner requires intent; see Purchase intent below.
idempotencyKey: "saved-order-42",
maxAmountUsdc: "5.00",
waitForApproval: { timeoutMs: 10 * 60_000, pollIntervalMs: 5_000 },
});- Approved → the payment executes and you get a settled result.
- Denied or expired →
GenesisPayApprovalRejectedError. - No decision within
timeoutMs→GenesisPayApprovalTimeoutError(the payment stays pending; surfaceerror.approvalUrlto the user and check again later withpaymentStatus()).
Paying a body-priced API (POST)
Some x402 APIs price or admit a request by its body — a forecast tiered by
horizon, a search priced by result count. Buy those with method: "POST"
and the exact JSON text of the body:
// Serialize once and save the string with the key: it is part of the purchase.
const body = JSON.stringify({ horizon: "7d", city: "Zurich" });
await saveOrder({ idempotencyKey: "forecast-20260924-k7f2qa", body });
const result = await agent.pay("https://api.example.com/v1/forecast", {
purchaseIntent: null, // Until the owner requires intent; see Purchase intent below.
idempotencyKey: "forecast-20260924-k7f2qa",
maxAmountUsdc: "0.05", // e.g. the discovery listing's priceUsdc
method: "POST",
body, // contentType defaults to (and only supports) "application/json"
});
console.log(result.requestMethod, result.bodySha256); // "POST", hex SHA-256 of the body- The body is part of the purchase identity. GenesisPay sends it
byte-for-byte — to the seller's 402 probe, the settlement preparation and the
signed request — and never re-serializes it. A retry with the same key must
pass the identical string; a different or merely reformatted body (other
spacing, other key order) is a different purchase and ends in
GenesisPayIdempotencyConflictError. There is deliberately nojson:helper: serializing for you would make the bytes depend on this SDK's version. - Validated before sending. At most 256 KiB of UTF-8
(
PURCHASE_BODY_MAX_BYTES), well-formed Unicode, valid JSON, and only withmethod: "POST". Anything else throwsGenesisPayPaymentRejectedError(request_body_invalid,request_body_too_large,unsupported_content_type) and nothing is sent. - Do not put secrets or personal data in the body. It is stored with the payment so an approved payment can be executed later with the same bytes; the approver sees its size and SHA-256, not its content.
- The echo check. A GenesisPay deployment that predates POST purchases
silently drops
methodandbodyand buys the URL with GET. The SDK therefore trusts a POST result only when the response echoesrequestMethod: "POST"and abodySha256equal to the hash it computed locally (WebCrypto). Otherwise it throwsGenesisPayPaymentOutcomeUnknownErrorwith thepaymentIdand your key — a payment may have been made for a different request and may already have been charged. Check it withpaymentStatus(), never retry under a new key, and buy with a body only once the deployment confirms POST purchases.
A discovery listing tells you which method to use: listing.method === "POST"
means pass method: "POST" and the body the API documents.
Waiting for an unconfirmed outcome
A payment can be accepted before it is confirmed: a hosted GenesisPay link with
the settlement queue answers 202, and the server records the payment
unresolved until the chain confirms it (the server already waits up to 12 s
before answering). Without options, pay() throws at once for unresolved
(GenesisPayUnresolvedPaymentError) and for approved/executing
(GenesisPayPaymentOutcomeUnknownError). Pass waitForOutcome to wait,
bounded, instead:
const result = await agent.pay("https://genesispay.example/pay/abc123", {
purchaseIntent: null, // Until the owner requires intent; see Purchase intent below.
idempotencyKey: "saved-order-43",
maxAmountUsdc: "1.00",
waitForOutcome: { timeoutMs: 30_000, pollIntervalMs: 2_000 }, // or `true` for these defaults
});- The client only reads
paymentStatus(); the delay doubles frompollIntervalMsup to 10 s and the last poll lands on the deadline. It never callsexecutePayment()— anapprovedpayment is being executed by the server — and never signs or re-sends anything. settled→ a settled result. For a queued hosted link,responseis the captured202settlement acceptance, not a content body.failed→GenesisPayPaymentFailedError;denied/expired→GenesisPayApprovalRejectedError; a status this SDK does not know →GenesisPayUnresolvedPaymentError.- Budget exhausted →
GenesisPayOutcomeWaitTimeoutError, a subclass ofGenesisPayPaymentOutcomeUnknownErrorcarryingpayment,paymentId,idempotencyKeyandwaitedMs. Not confirmed yet is not failed: the payment may already have been charged and may still settle. Keep pollingpaymentStatus(error.paymentId); never buy again with a new key. waitForApprovalis unchanged and independent: it is the option that executes an approved payment, and it still stops atunresolved.
API
| Method | Description |
| --- | --- |
| pay(url, options) | Strict v2 payment. Required: saved idempotencyKey; optional: maxAmount, maxAmountUsdc, asset, description, method, body, contentType, waitForApproval, waitForOutcome, requireIssuedKey (1.7.0). |
| createPurchaseKey() | (1.7.0) A GenesisPay-issued purchase key { purchaseKey, expiresAt } for one new purchase; charges nothing (see Purchase keys). |
| discover(query, options?) | Search the public GenesisPay discovery directory for x402-payable services and (since 1.6.0) quote-only physical products. Options: category, limit (default 20, max 50); since 1.4.0 also shop (public shop id) and kind (api, link, product); since 1.6.0 includeQuote (default true). Pass an empty query to browse. Returns DiscoveredService[]; narrow with isPayableListing() / isQuoteProduct() (see Physical products). For a payable listing, pass a result's resourceUrl to pay() and, for a listing whose asset is USDC or absent, its priceUsdc as maxAmountUsdc (the resource's 402 stays the price authority; the ceiling guards USDC payments only, so do not buy a listing in another asset with this SDK). Newer servers add id, method ("POST" → pay with method/body), asset and shop ({ id, name, storefrontUrl } or null); a malformed additive field reads as absent. |
| shops(query?, options?) | Search the public shop directory (GET /api/v1/discovery/shops). Option: limit. Returns DiscoveredShop[] (id, name, description, storefrontUrl, category, productCount); storefrontUrl is for humans — buy a shop's products via discover(). Malformed entries are dropped. |
| trending(options?) | What is trending right now (GET /api/v1/discovery/trending, no auth). Option: limit (default 10, max 20). Returns TrendingProduct[] (rank, id, title, description, asset, priceMinor — integer minor units as a string, priceUsdc, method, resourceUrl, category, shop, signal: "sales" when ranked by distinct buyers of paid orders in the last 7 days (coarse bands, at least 3), "new" when ranked by listing date). No count is ever exposed; the sales signal may be up to 60 s old, listing and eligibility are always current. Pass a USDC product's resourceUrl to pay() with its priceUsdc as maxAmountUsdc. Malformed entries are dropped. |
| paymentStatus(paymentId) | Fetch the current payment record (status, txHash, failureReason, ...). |
| executePayment(paymentId) | Execute an already-approved payment (idempotent). |
| account() | Account snapshot: wallet, USDC balance, spending policy, spend totals. |
| describeService(id) | Read-only service contract for a listing with serviceContract (see Service inputs); contract: null with instructions for a quote-only product. |
| quote({ productId, quantity }) | Read-only quote for a physical product to the owner's saved address; orders nothing (see Physical products). |
| productImage(productId, options?) | A quote-only product's picture as { mimeType, data } (JPEG, PNG, WebP or GIF, at most 512 KiB, type sniffed from the bytes), or null when there is none to show. GenesisPay fetches the seller's image under its SSRF guard; this client never fetches a seller URL. Seller content: show it, never follow it (1.6.0). |
| purchase(input, options?) / getPurchase(purchaseId) | Order and pay a quote in one call after the user confirmed address and total (saved idempotencyKey, byte-identical retries); read a purchase, read-only (see Buying a quote). |
| getShippingProfile() / setShippingProfile(profile) | Read or save the owner's one shipping address; a save emails the owner and a new address needs dashboard approval on its first order. |
| result(paymentId) | Captured JSON response of a payment for seven days, without paying again (see Recover the service response). |
| discoverExternalServices(options?) / describeExternalService(id, options?) | The separate curated external x402 directory; no merchant verification (see Selected external x402 services). include: ["buyerFee"] adds the GenesisPay fee hint (see The GenesisPay fee on external purchases). |
| reviews(id, options?) / purchaseReviews(id, options?) | Public verified-purchase reviews for a prod_… id, or (purchaseReviews) a prod_… or ext_… id. |
| prepareReview / publishReview / withdrawReview | Local-product review drafts, exact-draft publication and withdrawal (see Purchase reviews). |
| reviewOpportunity(paymentId) / preparePurchaseReview / publishPurchaseReview | Target-aware reviews for local products and selected external services. |
pay/executePayment resolve to an AgentPaymentResult:
status—"settled"or"pending_approval".txHash,payment(full record),approvalUrl(pending only).idempotencyKeyandreplayedpreserve purchase identity.requestMethod/bodySha256— the purchase request the server confirmed (nullwhen an older server sent no echo for a GET, and on results that did not come from a pay envelope).responseis explicitly null on replay or whenever the original capture is unavailable.bytes()/body()/json()— the paid resource's response (settled payments; throws a descriptive error when the capture is unavailable).
Errors
The separately gated experimental contract-mandate purchase API is
POST /api/v1/agent/mandates/:mandateId/pay. Use an already owner-approved
mandate and send idempotencyKey, decimal-integer grossMinor, resourceUrl
and optional description. Recipients, asset, network and fees are fixed by
the mandate. This endpoint reserves a payment; HTTP 202 is not settlement.
Read its paymentId with paymentStatus() until canonical confirmation.
Exact replay returns the same payment with HTTP 200; a changed request using
the same key returns 409. Resource URLs are audit metadata, not fulfillment.
Owner setup is a dashboard operation, never agent-key authority. This API is
off by default and has no Mainnet release clearance. The 1.x Agent SDK does
not export a helper for this route; use of the raw route is a separate reviewed
integration, not part of the #346 strict-payment canary.
executePayment() receiving HTTP 202 throws
GenesisPayPaymentOutcomeUnknownError with the original payment ID and key.
Keep polling that payment; do not create another purchase. The approval wait
loop continues polling after a queued execution response.
All API failures throw typed errors from @genesis-tech/genesispay-agent:
Every one of them extends GenesisPayApiError, so a single instanceof check is
exhaustive.
| Error | When | Safe to retry? |
| --- | --- | --- |
| GenesisPayPaymentFailedError | Nothing was signed — the payment was refused before any authorization existed. | Yes, nothing moved. |
| GenesisPayPaymentOutcomeUnknownError | Whether money moved is unknown. Covers a dropped connection or 5xx during pay()/executePayment(), and a waitForApproval timeout on a payment that was already executing. | Only with the same idempotencyKey. |
| GenesisPayUnresolvedPaymentError | The server confirmed it: the authorization reached the resource, the outcome is unknown. A subclass of the row above — carries payment. | Only with the same idempotencyKey. |
| GenesisPayOutcomeWaitTimeoutError | waitForOutcome ran out while the payment was still unresolved, approved or executing. Not confirmed yet — not failed. A subclass of GenesisPayPaymentOutcomeUnknownError; carries payment and waitedMs. | Only with the same idempotencyKey; keep polling paymentStatus(). |
| GenesisPayIdempotencyConflictError | 409 — the saved key identifies different or historical request terms. Carries original paymentId and payment when available. | Read the original payment; do not use a fresh key to bypass the conflict. |
| GenesisPayDuplicatePaymentError | Legacy 409 from an older API response. | Read the original payment. |
| GenesisPayAuthError | 401 — the agent credential was rejected or revoked: a missing or revoked gp_ag_ key, or, behind a hosted MCP endpoint, a disconnected OAuth connection. | After fixing the key or reconnecting the assistant. |
| GenesisPayPolicyBlockedError | 403 — the spending policy hard-blocked it: an allowlist miss, a revoked delegation, a revoked key, or a paused account. The message says which. | No — the owner must act. |
| GenesisPayPaymentRejectedError | Rejected before anything was signed — a bad or unreachable URL, a free resource, a requirement we cannot pay, an amount over your own ceiling, a refused purchase key (purchase_key_required, purchase_key_invalid, purchase_key_expired; 1.7.0), a curated service that is quarantined (external_service_quarantined) or a temporarily unreadable curated-service registry (external_registry_unavailable, 503), or a POST body refused locally (status: 0, nothing sent). Check error.code; note target_unreachable is a 502 because the failure is the seller's endpoint, so branch on the class, not the status. | Yes, once the cause is addressed. |
| GenesisPayApprovalRejectedError | The pending payment was denied or expired. | No. A denial is an answer. |
| GenesisPayApprovalTimeoutError | The waitForApproval window elapsed while the payment was still pending_approval. Nothing moved; the approval URL is still live. | Poll paymentStatus(). |
| GenesisPayCommerceError | A shipping-profile, quote() or purchase() refusal (1.6.0). This call charged nothing (for purchase() the server said this request created no payment; purchaseId names a created purchase, and for idempotency_conflict / commerce_quote_already_used an earlier one that may be paid). Branch on reason (closed list), read issues for refused profile fields. | Depends on reason; never with a new key for the same purchase. |
| GenesisPayApiError | Anything else. Network failures on read-only calls use code: "network_error", as does a result() answer that was cut off or not JSON (1.7.1; read again). From result(), code: "invalid_response" means a well-formed answer it does not recognise — an unknown result state, or a body under expired/unavailable (1.7.1): the content is withheld and nothing was charged; update the SDK, never buy again. | Depends. |
The distinction that matters.
failedmeans no money moved.GenesisPayPaymentOutcomeUnknownErrormeans we do not know. Treating the second like the first is how a buyer gets charged twice — so pass anidempotencyKeyon everypay()call and reuse it on any retry. The server replays the original matching payment without signing or fetching the resource again. Different request terms fail closed. Everypay()API error retains your savedidempotencyKey; there is no fallback to an unkeyed v1 request.
Migration from the optional-key client
This source version uses POST /api/v2/agent/pay; deploy v2 before upgrading the
client. V1 remains available to existing integrations. Keys are required at
compile time and runtime (trimmed, 1–200 characters). Generate and persist one
key per purchase, then reuse the same URL, maximum, asset filter, description
and — for a POST — method and byte-identical body on retries. Never generate a replacement key just because the first response
was lost. Decimal aliases normalize identically but must agree when both supplied.
Omitted asset selection differs from an explicit USDC filter.
A matching replay returns authoritative current state. Settled replay resolves
with replayed: true and response: null; call result(paymentId) for the retained response instead of paying again. Approved/executing
replays raise GenesisPayPaymentOutcomeUnknownError; unresolved raises its
subclass — unless waitForOutcome is set, which first waits for them.
Poll paymentStatus(error.paymentId). Failed/denied/expired replays
retain their terminal record and raise the corresponding typed error. Pending
approval still exposes the human approval URL.
Environment variables
GENESISPAY_AGENT_KEY— agent API key (gp_ag_...), created on the GenesisPay dashboard under your agent account's Keys tab.GENESISPAY_BASE_URL— base URL of the GenesisPay deployment. It must behttps://; plainhttp://is accepted only forlocalhostand127.0.0.1(local development). Any other base URL makes the constructor throw, because the agent key is a bearer credential that can spend (since 1.7.1).
Agent payments settle in USDC on Base in v1.
Amounts are integer USDC minor units (6 decimals) as strings everywhere in
the payment records; maxAmountUsdc is a human-readable decimal like
"0.50".
The GenesisPay fee on external purchases (1.5.0)
When an agent buys from an x402 provider that is not a GenesisPay seller, the
provider receives its full price and GenesisPay charges the payer a separate
fee on top (currently 1 % of the price, with a minimum; the hint's bps is
authoritative), only if the purchase settles.
maxAmount / maxAmountUsdc keep bounding the provider's price; the owner's
caps and approval limits apply to price plus fee. Tell the user the total
before paying.
Before paying, ask the curated directory for the hint:
const [service] = await agent.discoverExternalServices({ q: "search", include: ["buyerFee"] });
// e.g. service.buyerFee → { active: true, bps: 100, minMinor: "5000", feeMinor: "5000", totalMinor: "12000" }buyerFee is an estimate in minor units of priceHint.asset (active: false
with feeMinor: "0" while no fee applies); the payment snapshots its own fee.
It is absent without include, or when the server cannot compute it — absence
never means "no fee". An older deployment refuses include.
After paying, every payment record may carry three optional fields (absent on older servers):
| Field | Meaning |
| --- | --- |
| buyerFeeMinor | The fee snapshotted at creation, on top of amountUsdcMinor; "0" when none. |
| totalDebitMinor | amountUsdcMinor plus the fee that is or will be charged; the amount alone once the fee is not_charged or waived. |
| buyerFeeStatus | none, quoted (charged only if the purchase settles), pending, collected, not_charged (the purchase never settled) or waived (GenesisPay absorbs it). Open: a newer value parses as its string. |
feeUsdcMinor keeps meaning the seller-borne fee inside the amount of a
GenesisPay-seller purchase; it is "0" on an external one.
Recover the service response (1.4.0)
Deploy migration 0157 and the result endpoint before using agent.result(paymentId).
It makes one authenticated GET and returns {paymentId, paymentStatus, result}.
result.state is available, expired or unavailable. Available results have
capturedAt, expiresAt and response with exact JSON text body, bodyBytes,
bodySha256, HTTP status, mimeType and kind. Parse body only after checking
availability. Since 1.7.1 the state list is closed: only available carries a
response, and any other state — or a body under expired/unavailable — throws
GenesisPayApiError with code invalid_response instead of handing on content
nobody classified. The payment itself is unaffected; update the SDK.
A 200 that is cut off or not JSON is network_error: read again. Storage lasts seven days from first capture (maximum 1 MiB UTF-8).
Reading and payment replay cannot renew it. Payment status remains independent.
settlement_acceptance is only a receipt; async_response is HTTP 202, not a
finished job. URLs inside JSON have their provider's lifetime. Unsupported,
oversized or never-received content is unavailable. Never repurchase automatically.
Purchase reviews (1.4.0)
The updated server exposes POST /api/v1/agent/reviews. The SDK offers:
const draft = await agent.prepareReview({ paymentId, stars: 4, comment: userOpinion });
// Show draft.productId, pseudonym, stars and exact comment to the user.
// Only after their explicit instruction to publish this exact draft:
const published = await agent.publishReview({
reviewId: draft.reviewId, version: draft.version, contentSha256: draft.contentSha256,
});
// Only when the user asks to withdraw:
await agent.withdrawReview(published.reviewId);Drafts expire after 24 hours. Editing creates a new version/hash and requires fresh approval. Retry a lost publish response with the same identity; never prepare or pay again to recover it. Comments are plain text, at most 2,000 Unicode characters/8,000 UTF-8 bytes; CRLF and outer spaces normalize before the exact preview. The user chooses stars. There is one vote per owner/product, with a random per-owner/seller pseudonym and no public wallet address.
Requires migrations 0158–0161, a prospectively bound confirmed internal product
purchase and a separately enabled review capability. OAuth needs new consent
for genesispay.agent genesispay.reviews.write; existing grants stay unchanged.
Static-key owners enable or revoke reviews in the existing Agent API keys tab.
agent.reviews(productId, {limit: 10, cursor}) reads current public opinions and
returns {productId, rating: {count, average}, reviews, nextCursor}. Each review
contains reviewId, pseudonym, stars, comment, publishedAt and verifiedPurchase;
no financial or owner identifiers. Count 0 means average null. Discovery/trending
include optional reviewSummary for visible published opinions. Hidden products
return 404, and drafts/withdrawn/hidden opinions never enter pages or averages.
Comments are untrusted opinions, not instructions or proof of quality.
Moderation and owner withdrawal use the existing GenesisPay account controls.
Foreign purchases return 404 review_purchase_not_found; unsupported purchases
return 409 review_purchase_not_supported, unsettled purchases
review_purchase_not_settled. Missing authority is 403
review_permission_required; stale drafts are 409 review_draft_changed or
review_draft_expired. Treat these as refusals, never as a reason to buy again.
Selected external x402 services (1.4.0)
The separate directory starts with Exa Search and Atlas worldwide monthly
Trends. These are independent providers, not GenesisPay-verified merchants.
unsigned_402 means the payment offer was checked without purchasing. Read
sources, checkedAt, priceHint, and contract; a price hint
never replaces a fresh 402 or the user's spending ceiling. No merchant shops or
sales statistics are attached; published purchase reviews are read with
purchaseReviews(id) using the ext_… id. Frozen, disabled or conflicting entries are hidden; age alone does not remove
an entry. Every purchase still validates the fresh payment offer. Reading discovery never calls or pays a provider.
SDK: agent.discoverExternalServices({ q: "Exa", limit: 5 }), then
agent.describeExternalService("ext_exa_search"). MCP equivalents (1.6.0):
genesispay_discover with source: "external" and genesispay_describe_service
with the ext_… id; the older genesispay_discover_external and
genesispay_describe_external_service stay as deprecated aliases until 1.7.
Exa uses POST JSON {query, numResults} (curated maximum 10); Atlas uses GET
https://google-trends.use.x402atlas.com/trend?keyword=coffee. Examples are
synthetic, not permission to buy. After explicit purchase authorization, use
the existing pay flow and an authorized ceiling; recover captured JSON with
result(paymentId) for seven days. Atlas relative interest is not sales data.
Selected external purchases and review invitations
Migration 0162 prospectively binds new Exa/Atlas purchases. Existing payments do
not gain attribution. agent.reviewOpportunity(paymentId) is a read-only hint:
state, permission, optional target, stable promptKey, nextAction and an
existing owned draft when reusable. The optional read has a 3 s timeout. It never
pays, prepares a draft or grants permission. Ask once after presenting the usable
result; respect a decline and defer for unfinished jobs. Review permission still
requires the owner's key capability or separately consented OAuth scope.
Use agent.preparePurchaseReview({paymentId,stars,comment}), show its exact
target, pseudonym, stars and comment, then call agent.publishPurchaseReview
with the shown reviewId/version/contentSha256 only after explicit approval.
Both methods opt into representation:"target_v1" on the existing review route.
target is {kind:"local_product",productId,title?} or
{kind:"external_service",serviceId,title?}. Legacy prepareReview/publishReview
remain local-only, with unchanged responses. Withdraw is shared.
agent.purchaseReviews("ext_exa_search", {limit:10}) reads public external
reviews; local product IDs also work and return the same target-based page.
External discovery keeps its existing strict shape. Use purchaseReviews for ratings. These are purchase-backed opinions,
not GenesisPay merchant verification. No wallet address is public.
