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.
# 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=S256is required; a request without it, or withplain, is refused. - Confidential clients only. Authenticate at the token endpoint with
client_secret_basicorclient_secret_post. - Exact redirect URIs. The
redirect_urimust match one you registered, character for character. - Query response mode with
iss. The code comes back as query parameters together withiss(RFC 9207), so you can check which issuer answered.nonce,prompt(none,login,consent) andmax_ageare supported;requestandrequest_uriare not.
Scopes and claims
| Scope | Claims |
|---|---|
openid | Required. Releases sub only. |
profile | name, preferred_username — each only when the account has one. |
email | email, email_verified — absent for accounts without an email (for example wallet-only accounts), even when granted. |
genesispay.wallet | genesispay_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 akidheader that names its key in the JWKS (/.well-known/jwks.json). Valid for 10 minutes. It carriesnonce,auth_timeandat_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 ashttp://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 — 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.
// 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// 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 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.
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 asclientReferenceId, areturnUrl, and a stableidempotencyKey. Redirect the buyer topayUrl. - After the payment confirms, the hosted checkout sends the buyer back to your
returnUrlautomatically 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 carriesgenesispay_link_idandgenesispay_status. They are unsigned and only a locator: never trustgenesispay_status, and always run verify — with the link id you stored, not the one in the URL. - Call
genesispay.fulfillment.verifywith the link as locator and deliver whenverifiedistrue. 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. Usebuyer?.subto confirm who paid, or to attribute a payment to an account when your link is not bound to one. - Claim each
attemptIdonce, 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 outsideopenid profile email genesispay.wallet, or left outopenid. Agent scopes such asgenesispay.agentbelong 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— aprompt=nonerequest 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 fromprompt=noneas a reason to start a normal sign-in.404on 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.