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:

Environment
# 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.

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.

Terminal
npm install @genesis-tech/genesispay-seller
create-checkout.ts
import { 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 here

baseUrl 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”.

poll-checkout.ts
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:

return-route.ts
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:

Terminal
# 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.

route.ts
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.