Quickstart: Seller
Get paid by humans and agents
Every GenesisPay link renders a hosted checkout for browsers and a machine-readable x402 response for agents — same URL, same payment object, settled in USDC. This guide goes from login to your first paid link.
1. Log in and create a key
GenesisPay is a hosted app: your account lives at the origin you are reading these docs on. Log in there with Google, email, or a wallet — no crypto setup required. Pick “Accept payments” in onboarding: GenesisPay assigns a receiving wallet for your payouts (you can override it in Settings) and walks you through creating a seller API key. You can also issue keys any time on the dashboard's Developers page.
Keys look like gp_sk_... and are shown once — store yours as GENESISPAY_SELLER_KEY. You only need a key for programmatic access; links can also be created entirely in the dashboard.
The examples below use two environment variables:
# The origin your GenesisPay account lives on — the same URL
# you're reading these docs on (e.g. your deployed app URL,
# or http://localhost:3000 in local dev).
export GENESISPAY_BASE_URL="https://<your-genesispay-host>"
# The gp_sk_... key from the dashboard's Developers page.
export GENESISPAY_SELLER_KEY="gp_sk_your_seller_key"GENESISPAY_BASE_URL is the origin where your GenesisPay account lives — the same URL you are reading these docs on (for the hosted beta, your deployed app URL; for local dev, http://localhost:3000). There is no separate API host: the app, the dashboard, and the API all share this origin. GENESISPAY_SELLER_KEY is the gp_sk_... key you just created on the Developers page.
2. Create a payment link
Create a link in the dashboard under Links → New, or programmatically:
curl -X POST "$GENESISPAY_BASE_URL/api/v1/links" \
-H "Authorization: Bearer gp_sk_your_seller_key" \
-H "GENESISPAY-Version: 2026-08-26" \
-H "Idempotency-Key: order_A-1042" \
-H "Content-Type: application/json" \
-d '{
"title": "Market report",
"description": "One CSV export of the latest report",
"amount": "5.00",
"asset": "USDC",
"linkType": "single",
"metadata": { "orderId": "A-1042" },
"clientReferenceId": "order_1042",
"returnUrl": "https://shop.example/thanks",
"cancelUrl": "https://shop.example/cart"
}'{
"requestId": "req_...",
"apiVersion": "2026-08-26",
"link": {
"publicId": "abc123",
"payUrl": "https://your-genesispay-host/pay/abc123",
"title": "Market report",
"amount": "5.00",
"amountUsdc": "5.00",
"amountUsdcMinor": "5000000",
"asset": "USDC",
"destinationWallet": "0x...",
"chainId": 84532,
"linkType": "single",
"status": "active",
"metadata": { "orderId": "A-1042" },
"clientReferenceId": "order_1042",
"returnUrl": "https://shop.example/thanks",
"cancelUrl": "https://shop.example/cart"
}
}Versioned creation requires an Idempotency-Key. Reusing the same key with the same input returns the original link—even after API-key rotation; reusing it with different input returns a conflict. Every versioned response carries a request ID for support and logs.
Match the key mode to the payment chain: test for Base Sepolia, live for Base mainnet. Strict creation and product gates refuse 409 seller_mode_mismatch before exposing or settling a payment that the same key could not verify. A baseUrl override does not change the key mode.
destinationWallet is optional — it defaults to your receiving wallet. Payment requests are always single: they close after payment. Explicit reusable creation is rejected; create a product for repeat sales. amount is a decimal string in the link's asset — dollars for USDC, euros for EURC — and every response also carries it as integer minor units (6 decimals) in amountUsdcMinor. amountUsdc is the deprecated older name for amount: still accepted and still returned, but it named a currency the value did not always have.
metadata (a flat map of up to 20 string key/value pairs) and clientReferenceId are yours to use for correlation: GenesisPay stores them untouched and echoes them back when you read the link and on every webhook for it — so you can match an incoming payment to your own order without a lookup table. Exact limits are in the API reference.
returnUrl and cancelUrl bring the payer back to your site. After a confirmed payment the checkout shows a Return to your-shop button pointing at returnUrl with ?genesispay_link_id=<publicId>&genesispay_status=paid appended (your own query parameters are preserved); the unpaid checkout offers cancelUrl as a quiet way out. Both must be https URLs — http is accepted only for localhost and 127.0.0.1. There is no timed auto-redirect: the payer decides when to leave the confirmed on-chain view.
Those two query parameters are a UI hint, not proof of payment — the SDK section shows how to verify before fulfilling.
3. Or use the SDK
The 1.0 GenesisPay client wraps the same endpoints for JavaScript and TypeScript — on Node, Edge runtimes, Workers, and Bun.
npm install @genesis-tech/genesispay-sellerimport { GenesisPay } from "@genesis-tech/genesispay-seller";
const genesispay = new GenesisPay({
apiKey: process.env.GENESISPAY_SELLER_KEY!, // gp_sk_...
baseUrl: process.env.GENESISPAY_BASE_URL, // your GenesisPay origin
});
const checkout = await genesispay.checkout.create({
title: "Market report",
// Decimal string in `asset` — dollars for USDC, euros for EURC.
// (`amountUsdc` is the deprecated pre-0.6.0 name for this field.)
amount: "5.00",
asset: "USDC",
linkType: "single",
// Seller-selected inclusive tax for this item; never inferred by GenesisPay.
taxConfig: { version: 1, treatment: "taxable", rateBps: 2000, note: null },
// Your identifiers, echoed by retrieve() and on every webhook:
clientReferenceId: order.id,
metadata: { orderId: order.id, buyerId: user.id },
returnUrl: "https://shop.example/thanks",
cancelUrl: "https://shop.example/cart",
}, {
// Stable per order and scoped to this seller account, not this API key.
idempotencyKey: `checkout:${order.id}`,
});
checkout.publicId; // "abc123"
checkout.payUrl; // send the payer herebaseUrl is your GenesisPay origin — the same GENESISPAY_BASE_URL as above. Omit it only when you are on the hosted facilitator that matches your key mode (gp_sk_test_ / gp_sk_live_). The receiving wallet and network are resolved from the key, so no wallet address appears in your code.
checkout.retrieve(publicId) reads a tolerant link view for display and polling. Its paid field is not fulfillment authority, and for reusable links it only means “ever paid”.
import { GenesisPayNotFoundError } from "@genesis-tech/genesispay-seller";
try {
const session = await genesispay.checkout.retrieve(checkout.publicId);
session.paid; // true once >= 1 payment is confirmed on-chain
session.confirmedPaymentCount; // 0, or how often a reusable link was paid
session.clientReferenceId; // "order_1042" — exactly what you sent
session.metadata; // { orderId: "A-1042" } | null
// Display/polling state only. Never authorize fulfilment from this tolerant
// object; use fulfillment.verify with the immutable expected contract.
renderPaymentState(session);
} catch (error) {
if (error instanceof GenesisPayNotFoundError) {
// Unknown publicId, or one belonging to another account — not a config bug.
}
throw error;
}Polling, a webhook, and the browser return are all triggers or recovery hints. Fulfil only after fulfillment.verify retrieves strict seller-scoped evidence and returns verified: true.
For an account-bound order, also check that the verified link belongs to your stored order and authenticated buyer. A product match does not identify your customer. Store the checkout link with the order and atomically claim the verified attempt before granting credits or goods.
When the payer returns, parseCheckoutReturnHint reads the genesispay_link_id / genesispay_status parameters off the URL — then you use that link only as a locator for strict verification:
import { parseCheckoutReturnHint } from "@genesis-tech/genesispay-seller";
export async function GET(request: Request) {
const hint = parseCheckoutReturnHint(new URL(request.url));
if (!hint) return Response.json({ ok: true }); // no return params — nothing to do
// The URL is only a locator hint. GenesisPay retrieves seller-scoped evidence
// and compares every authority field; no caller-created evidence is accepted.
const result = await genesispay.fulfillment.verify({
locator: { linkId: hint.linkId },
expected: expectedSingleUseLinkContract,
});
if (result.verified) await fulfilOnce(result.payment.attemptId);
return Response.json({ ok: true });
}A link-only locator is suitable for single-use links. A reusable link's paid means “ever paid” and is ambiguous; fulfil those by strict verification keyed by the webhook or recovery attempt.id.
Never fulfil on genesispay_status=paid
Those two query parameters are not signed and prove nothing. Anyone who opens a checkout can note its publicId, abandon the payment, and call https://shop.example/thanks?genesispay_link_id=…&genesispay_status=paid by hand — a landing page that ships goods on that signal ships them for free.
parseCheckoutReturnHint performs no verification; it only tells you which link to look up. A payment.confirmed webhook is an authenticated trigger, not payment authority. Gate every fulfilment on fulfillment.verify returning verified: true for your immutable expected contract.
4. Get paid — by anyone
Share payUrl. Humans open it as a normal checkout page and pay with their wallet. Agents request the exact same URL over HTTP and get an x402 payment requirement instead:
# The same URL is an x402 endpoint for agents:
curl -i "$GENESISPAY_BASE_URL/pay/abc123" -H "Accept: application/json"
# -> 402 Payment Required + PAYMENT-REQUIRED header (x402 V2)You do nothing extra for the agent side — GenesisPay negotiates content per client and verifies every USDC transfer on-chain before a payment counts. Track payments per link in the dashboard, or register a webhook to be notified when money arrives.
Optional: gate your own API
If you would rather charge for an endpoint you already run, create a product with delivery: { type: "gate" } and protect the matching route with @genesis-tech/genesispay-seller. The canonical product link supplies the price, USDC asset, Base network and receiving wallet; the browser never chooses money values.
import {
GenesisPay,
createGateRequestFingerprint,
} from "@genesis-tech/genesispay-seller";
const genesispay = new GenesisPay({
apiKey: process.env.GENESISPAY_SELLER_KEY!,
expectedPayTo: process.env.GENESISPAY_EXPECTED_PAY_TO!,
});
// Create this once in provisioning, then store the returned product ID.
const forecastGate = genesispay.products.gate(
process.env.GENESISPAY_FORECAST_PRODUCT_ID!,
);
export async function POST(request: Request) {
const body = await request.clone().text();
validateForecastRequest(body); // reject invalid input before charging
const expectedForRequest = {
...expectedForecastContract,
delivery: {
type: "gate" as const,
url: null,
gate: {
...expectedForecastContract.delivery.gate,
fingerprint: await createGateRequestFingerprint(request),
},
},
};
return forecastGate.protect(request, expectedForRequest, async (_request, purchase) => {
// The handler runs only after strict authority verification. Persist/reuse
// results by the verified attempt ID because delivery is at-least-once.
const forecast = await getOrCreateForecast({
paymentAttemptId: purchase.payment.attemptId,
body,
});
return Response.json(forecast);
});
}expectedPayTo pins the destination for product contract checks, checkout creation and retrieval, gate protection, and strict fulfillment verification. A conflicting expected destination fails locally before a request can create or settle a payment. Strict checkout errors retain requestId and apiVersion when the response provides them.
After a receiving-wallet change, reconcile older payments with a separate client pinned to the original destination saved in your order contract. An expected contract never overrides the client pin, and changing the pin never changes a payment’s frozen destination.
The first valid request receives 402 Payment Required. GenesisPay binds its pending attempt to the method, registered URL and a raw-body fingerprint, settles the EIP-3009 USDC authorization, then retrieves and compares strict evidence before running your handler. Save effects by the verified payment attempt ID because a confirmed retry can invoke the handler again. Once your endpoint is live, consider listing it in discovery so agents can find it.
Pricing models
GenesisPay charges per call: every link and every gated endpoint has one fixed amount that is collected on each paid request. That covers pay-per-call APIs, one-off purchases, and metered access where each request is its own charge.
Subscriptions and metered pay-per-use are covered by payment mandates: the customer signs one gasless spending approval, and you charge per use or per period without a signature per charge — funds still move directly from the payer's wallet to yours. Per-call x402 stays the native fit for agents; mandates add the recurring and metered models on top.