Sign in with GenesisPay

Log buyers in with GenesisPay

GenesisPay is an OpenID Connect provider. Buyers sign in to your app with their GenesisPay account, and you get a stable, private user id for them. Combined with single-use payment links, a signed-in buyer pays for each use with one click.

Beta

Sign in with GenesisPay is in beta and switched on per deployment. It is available on Development; Production follows after release. Until then, the Production discovery URL answers 404.

How it works

Point any standard OpenID Connect library at the discovery document. It names every endpoint, the supported scopes and the signing keys.

Discovery URL
# Production (after release)
https://genesispay.finance/.well-known/openid-configuration

# Development (available now)
https://dev.genesispay.finance/.well-known/openid-configuration
  • Authorization code flow with PKCE. code_challenge_method=S256 is required; a request without it, or with plain, is refused.
  • Confidential clients only. Authenticate at the token endpoint with client_secret_basic or client_secret_post.
  • Exact redirect URIs. The redirect_uri must match one you registered, character for character.
  • Query response mode with iss. The code comes back as query parameters together with iss (RFC 9207), so you can check which issuer answered. nonce, prompt (none, login, consent) and max_age are supported; request and request_uri are not.

Scopes and claims

ScopeClaims
openidRequired. Releases sub only.
profilename, preferred_username — each only when the account has one.
emailemail, email_verified — absent for accounts without an email (for example wallet-only accounts), even when granted.
genesispay.walletgenesispay_wallet — the account's GenesisPay wallet address, when it has one.

Any other scope makes the whole request fail with invalid_scope. email_verified is true only when GenesisPay confirmed the address through its login provider; otherwise it is false.

sub is pairwise per seller. The same buyer has the same sub in all of your clients (for example web and mobile) and a different one at every other seller, so two sellers cannot match a buyer through it. It never changes: store it as your user key. Development and Production are separate deployments with separate accounts, so the same person has a different sub on each. Do not key users on email, which is optional and can change.

Tokens and lifetimes

  • id_token: a JWT signed with RS256, with a kid header that names its key in the JWKS (/.well-known/jwks.json). Valid for 10 minutes. It carries nonce, auth_time and at_hash.
  • Access token: opaque, valid for 15 minutes, and accepted only by the userinfo endpoint.
  • Refresh token: rotates on every use. Presenting an already used refresh token revokes the buyer's sign-in with your app, and they have to sign in again — so never refresh the same token from two requests at once.

Identity tokens never grant payment authority. None of them works at /api/v1, the agent API or /mcp, and no scope lets you charge a buyer. Every payment is still one the buyer signs on the hosted checkout.

Register a client

In the dashboard, open Developers, then Sign in with GenesisPay clients, and create a client. On Production, only a seller account with a verified business (KYB approved) can create a client; verify your business first under Business verification. Clients you already have keep working either way.

  • Name — shown to buyers on the consent screen next to your business name and the redirect host.
  • Redirect URIs — one to ten exact URIs, each https. Loopback addresses such as http://127.0.0.1:3000/… are accepted only on deployments that enable them for local development, never on Production.
  • Client secret — shown once, when you create the client. Store it in your server environment. You can rotate it, which invalidates the old secret, or disable the client, which stops every sign-in through it.

The client id starts with gpi_ and is public; the secret starts with gp_ics_ and must never reach a browser.

Example: better-auth

better-auth's genericOAuth plugin reads the discovery document, sends PKCE and a nonce, and verifies the id_token against the JWKS.

auth.ts
// auth.ts — better-auth 1.7 with the genericOAuth plugin
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";

export const auth = betterAuth({
  // database, baseURL, secret … as in your existing setup
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "genesispay",
          discoveryUrl:
            "https://genesispay.finance/.well-known/openid-configuration",
          clientId: process.env.GENESISPAY_OIDC_CLIENT_ID!,         // gpi_…
          clientSecret: process.env.GENESISPAY_OIDC_CLIENT_SECRET!, // gp_ics_…
          tokenEndpointAuth: { method: "client_secret_basic" },
          scopes: ["openid", "profile", "email"],
          pkce: true, // the default — GenesisPay refuses a request without S256
          requireIdTokenVerification: true, // verify id_tokens against the JWKS
          mapProfileToUser: (profile) => {
            const username =
              typeof profile.preferred_username === "string"
                ? profile.preferred_username
                : undefined;
            return {
              name: profile.name || username || "GenesisPay buyer",
              // better-auth refuses a sign-in without an email, and wallet-only
              // GenesisPay accounts have none. Fall back to a reserved,
              // undeliverable .invalid address derived from the stable sub.
              email: profile.email ?? `${String(profile.sub)}@buyers.genesispay.invalid`,
              emailVerified: profile.email ? profile.emailVerified === true : false,
            };
          },
        },
      ],
    }),
  ],
});

Buyers without an email. better-auth needs an email for every user and refuses a sign-in whose profile has none. A GenesisPay account can have no email at all, so requesting the email scope is not enough. The mapProfileToUser fallback above gives those users a per-sub address on the reserved .invalid domain, marked unverified. Never send mail to an address ending in .invalid, and ask the buyer for a real one if you need it.

Sign-in button
// In the browser — generic providers use the core social sign-in.
await authClient.signIn.social({
  provider: "genesispay",
  callbackURL: "/predictions",
});

// Register this exact redirect URI on your GenesisPay client:
// https://predict.example/api/auth/callback/genesispay
Read the GenesisPay sub
// Server side: the GenesisPay sub of the signed-in user.
// better-auth stores the provider's sub as the account's accountId.
export async function genesisPaySub(headers: Headers): Promise<string | null> {
  const accounts = await auth.api.listUserAccounts({ headers });
  return accounts.find((account) => account.providerId === "genesispay")?.accountId ?? null;
}

Example: any OIDC library

Without better-auth, any certified OpenID Connect relying-party library works. With openid-client v6:

openid-client
// openid-client v6
import * as client from "openid-client";

const config = await client.discovery(
  new URL("https://genesispay.finance"),
  process.env.GENESISPAY_OIDC_CLIENT_ID!,
  process.env.GENESISPAY_OIDC_CLIENT_SECRET!, // sent as client_secret_post by default
);
const redirectUri = "https://predict.example/auth/genesispay/callback";

// 1. Start: keep verifier, state and nonce in your server-side session.
export async function startSignIn(session: OidcSession): Promise<URL> {
  const codeVerifier = client.randomPKCECodeVerifier();
  const state = client.randomState();
  const nonce = client.randomNonce();
  session.oidc = { codeVerifier, state, nonce };
  return client.buildAuthorizationUrl(config, {
    redirect_uri: redirectUri,
    scope: "openid profile email",
    code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
    code_challenge_method: "S256",
    state,
    nonce,
  });
}

// 2. Callback: exchange the code, check state and nonce, read the claims.
export async function finishSignIn(session: OidcSession, currentUrl: URL) {
  const { codeVerifier, state, nonce } = session.oidc;
  const tokens = await client.authorizationCodeGrant(config, currentUrl, {
    pkceCodeVerifier: codeVerifier,
    expectedState: state,
    expectedNonce: nonce,
    idTokenExpected: true,
  });
  const claims = tokens.claims()!;
  const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
  // Key your user on claims.sub. Email is optional and may be missing.
  return { sub: claims.sub, email: userinfo.email ?? null };
}

Refresh with client.refreshTokenGrant(config, refreshToken) and replace the stored refresh token with the new one each time.

Pay per use with one click

For metered products, such as one prediction or one report, create one single-use payment link per request. A buyer who is already signed in to GenesisPay pays it with one click, and you check that the payment came from the buyer who is signed in to your app.

Seller SDK
import {
  GenesisPay,
  type ExpectedPaymentLinkContract,
  type NetworkContract,
} from "@genesis-tech/genesispay-seller";

const genesispay = new GenesisPay({
  apiKey: process.env.GENESISPAY_SELLER_KEY!,             // gp_sk_live_…
  expectedPayTo: process.env.GENESISPAY_EXPECTED_PAY_TO!, // your payout wallet
});

const PRICE = "0.10";           // decimal string for checkout.create
const PRICE_MINOR = 100_000n;   // the same price in USDC minor units (6 decimals)
const BASE_USDC: NetworkContract = {
  mode: "live",
  network: "base",
  chainId: 8453,
  asset: "USDC",
  tokenAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  minorUnitScale: 6,
};

// 1. The signed-in buyer asks for a prediction: one single-use link per request.
export async function startPrediction(buyerSub: string, input: PredictionInput) {
  const prediction = await db.predictions.create({ buyerSub, input });
  const link = await genesispay.checkout.create(
    {
      title: "One prediction",
      amount: PRICE,
      // Human checkout issues a document per payment, so every link needs a
      // tax treatment (missing → 409 item_tax_required on /pay).
      taxConfig: { version: 1, treatment: "taxable", rateBps: 2000, note: null },
      linkType: "single",
      clientReferenceId: prediction.id,
      returnUrl: `https://predict.example/predictions/${prediction.id}/return`,
      cancelUrl: `https://predict.example/predictions/${prediction.id}`,
    },
    // A retry with the same key returns the same link, never a second one.
    { idempotencyKey: `prediction:${prediction.id}` },
  );
  await db.predictions.update(prediction.id, { linkId: link.publicId });
  return Response.redirect(link.payUrl, 303); // one click on /pay
}

// 2. The buyer comes back (auto-redirect after 5 s, or the Return button).
// genesispay_link_id / genesispay_status on this URL are unsigned hints:
// never trust genesispay_status — always verify with the stored link id.
export async function finishPrediction(buyerSub: string, predictionId: string) {
  const prediction = await db.predictions.get(predictionId);
  if (!prediction || prediction.buyerSub !== buyerSub) return notFound();

  const expected: ExpectedPaymentLinkContract = {
    kind: "payment_link",
    linkId: prediction.linkId,
    grossAmountMinor: PRICE_MINOR,
    network: BASE_USDC,
    settlementDestination: process.env.GENESISPAY_EXPECTED_PAY_TO! as `0x${string}`,
  };
  const result = await genesispay.fulfillment.verify({
    locator: { linkId: prediction.linkId },
    expected,
  });
  if (!result.verified) return showPending(result.reason);

  // The link was bound to this prediction (and its buyer) when you created
  // it, so a verified payment is always delivered. buyer is { sub } | null:
  // null for an agent payer or a payer without a live sign-in with you.
  // A different sub means someone else paid for this prediction: still
  // deliver (it is paid), and record it for your own review.
  if (result.buyer?.sub !== buyerSub) await flagForReview(prediction.id, result.buyer);

  await deliverPredictionOnce(prediction.id, result.payment.attemptId);
}
  • Create the link on your server with genesispay.checkout.create, your own id as clientReferenceId, a returnUrl, and a stable idempotencyKey. Redirect the buyer to payUrl.
  • After the payment confirms, the hosted checkout sends the buyer back to your returnUrl automatically after a five-second countdown; a Return to merchant button stays on screen to go back at once or if the redirect does not happen. The return URL carries genesispay_link_id and genesispay_status. They are unsigned and only a locator: never trust genesispay_status, and always run verify — with the link id you stored, not the one in the URL.
  • Call genesispay.fulfillment.verify with the link as locator and deliver when verified is true. The expected contract pins the amount, network and payout wallet; a mismatch throws instead of verifying. Because you bound the link to the prediction when you created it, a verified payment is always delivered — never refuse a paid buyer. Use buyer?.sub to confirm who paid, or to attribute a payment to an account when your link is not bound to one.
  • Claim each attemptId once, so a reload or a webhook never delivers twice.

buyer is { sub } when the payer's GenesisPay account has a live sign-in with one of your clients, and null when it has none, when the buyer revoked it, or when an agent paid. It is your pairwise sub, never an email or wallet. buyer ships in seller SDK 1.8.0, which is prepared but not yet published; on older versions read it from the POST /api/v1/fulfillment/verify response.

Before the first link: a human checkout issues a payment document, so your shop needs its verified business details and tax registration, and every link needs a taxConfig. Without them /pay answers invoice_setup_required or item_tax_required instead of the one-click payment.

Each link pays the seller-paid fee, including its minimum fee (see Pricing and fees). At very small prices the minimum is a large share of the payment.

Errors and troubleshooting

  • invalid_scope — the request named a scope outside openid profile email genesispay.wallet, or left out openid. Agent scopes such as genesispay.agent belong to the remote MCP and can never be mixed into a sign-in.
  • Redirect URI mismatch — GenesisPay shows an error page and does not redirect, because it cannot trust the URI. Compare the exact string, including scheme, trailing slash and port, with the client's registered URIs.
  • invalid_grant — the code expired (it lives 60 seconds), was already used, or the PKCE verifier or redirect URI does not match. Reusing a code also revokes the buyer's sign-in with your app.
  • login_required — a prompt=none request found no signed-in GenesisPay session. consent_required — the buyer is signed in but has not approved your app for these scopes. Treat any error from prompt=none as a reason to start a normal sign-in.
  • 404 on the discovery URL — Sign in with GenesisPay is not switched on for that deployment yet.

Privacy

Paying a link ties the buyer's on-chain wallet to their account with you, even if you never requested genesispay.wallet: you see the payer address on chain and the buyer's sub from verify. GenesisPay states this on the consent screen. Say so in your own privacy notice too, and request only the scopes you use.

Buyers can review and revoke your app under their GenesisPay account's connected apps. After a revoke, userinfo and refresh fail and buyer comes back null.