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 "Content-Type: application/json" \
-d '{
"title": "Market report",
"description": "One CSV export of the latest report",
"amount": "5.00",
"asset": "USDC",
"linkType": "reusable",
"metadata": { "orderId": "A-1042" },
"clientReferenceId": "order_1042",
"returnUrl": "https://shop.example/thanks",
"cancelUrl": "https://shop.example/cart"
}'{
"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": "reusable",
"status": "active",
"metadata": { "orderId": "A-1042" },
"clientReferenceId": "order_1042",
"returnUrl": "https://shop.example/thanks",
"cancelUrl": "https://shop.example/cart"
}
}destinationWallet is optional — it defaults to your receiving wallet. linkType is single (one payment, then marked paid) or reusable (unlimited payments — ideal for pay-per-call APIs). 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 legacy 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: "reusable",
// 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",
});
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 link's current state. paid is the value to poll on: true from the first on-chain confirmation, for reusable links too.
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
if (session.paid) {
await fulfil(session.clientReferenceId);
}
} catch (error) {
if (error instanceof GenesisPayNotFoundError) {
// Unknown publicId, or one belonging to another account — not a config bug.
}
throw error;
}Polling is the fallback — a webhook tells you the same thing without the loop. Either way, that is the check that decides fulfilment; the payer's return URL is not.
When the payer returns, parseCheckoutReturnHint reads the genesispay_link_id / genesispay_status parameters off the URL — then you confirm with an authenticated retrieve before acting:
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
// Verify with your seller key, never trust the URL:
const session = await genesispay.checkout.retrieve(hint.linkId);
// Only single-use links are fulfilled here. A reusable link's "paid" means
// "ever paid"; fulfil those on a payment.confirmed webhook keyed by attempt.id.
if (session.linkType !== "single") return Response.json({ ok: true });
// Single-use link: fulfil once, keyed by the link itself.
if (session.paid) await fulfilOnce(hint.linkId);
return Response.json({ ok: true });
}The sample fulfils single-use links only: it keys on hint.linkId and refuses any other linkType. A reusable link's paid means “ever paid” and the return URL cannot say which payment triggered it — fulfil those on a verified payment.confirmed webhook keyed by attempt.id instead.
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. Gate every fulfilment on a verified payment.confirmed webhook or on checkout.retrieve(publicId).paid — both authenticated with your seller key and backed by the on-chain confirmation.
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 } from "@genesis-tech/genesispay-seller";
const genesispay = new GenesisPay({
apiKey: process.env.GENESISPAY_SELLER_KEY!,
expectedPayTo: process.env.GENESISPAY_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
return forecastGate.protect(request, async (_request, purchase) => {
// Payment confirmation is exactly-once; handler delivery is at-least-once.
// Persist/reuse results by purchase.payment.attemptId.
const forecast = await getOrCreateForecast({
paymentAttemptId: purchase.payment.attemptId,
body,
});
return Response.json(forecast);
});
}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, and then runs your handler. Save effects by the 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.