Observed since 20 Oct, 22:11 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
0G Pay SDK - TypeScript/React library for 0G payment integration
SDK for accepting deposits and card payments on the 0G network.
StripeClient — headless Node.js / server-side client: checkout, balance, usage, topup history — no React required/server subpath — React-free bundle for pure Node.js projects (no react/react-dom peer deps needed)npm install @0gfoundation/0g-pay-sdk
Peer dependencies (React projects only)
npm install react react-dom
If you only need the Node.js / server-side client (StripeClient), import from the /server subpath — no React peer deps required:
import { StripeClient } from '@0gfoundation/0g-pay-sdk/server';
import { OGPay } from '@0gfoundation/0g-pay-sdk';
export function App() {
const provider = window.ethereum; // any EIP-1193 provider
return <OGPay provider={provider} />;
}
Clicking the Deposit button opens a modal with the TokenFlight widget, letting users swap any token or pay by card.
import { OGPay } from '@0gfoundation/0g-pay-sdk';
export function App() {
const provider = window.ethereum;
return (
<OGPay
provider={provider}
stripe={{
network: 'testnet',
sessionToken: yourSessionToken,
successUrl: `${window.location.origin}/topup/return`,
}}
/>
);
}
When stripe is provided, the modal gains a Card (Stripe) tab. Users pick a USD amount, click pay, and are redirected to Stripe hosted checkout. After payment, Stripe redirects back to your successUrl.
The Stripe flow has three parts: obtaining a session token, triggering the checkout, and handling the return page.
The 0G Pay backend requires a session token for all API calls. You exchange your user's Privy access token for a Pay session token once per session.
import { getSessionUrl } from '@0gfoundation/0g-pay-sdk';
async function getPaySessionToken(privyAccessToken: string): Promise<string> {
const res = await fetch(getSessionUrl('testnet'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ privy_token: privyAccessToken }),
});
if (!res.ok) throw new Error(`Session exchange failed: ${res.status}`);
const { token } = await res.json();
return token;
}
Store the token in component state or your auth context — it is valid until the JWT expires.
Pass the session token (and optionally an email) into the stripe prop. The successUrl must point to a page in your app that renders the StripeReturn component (see Step 3).
import { OGPay } from '@0gfoundation/0g-pay-sdk';
export function TopupButton({ sessionToken, email }: { sessionToken: string; email?: string }) {
const provider = window.ethereum;
return (
<OGPay
provider={provider}
stripe={{
network: 'testnet',
sessionToken,
email, // pre-fills Stripe checkout
successUrl: `${window.location.origin}/topup/return`, // your return page
cancelUrl: window.location.href, // where to go on cancel
}}
/>
);
}
When the user opens the modal and clicks the Card (Stripe) tab, they see:
Clicking the button calls POST /v1/checkout on the Pay backend and redirects the browser to the Stripe hosted checkout page.
After payment, Stripe appends ?session_id=cs_... to your successUrl and redirects the user there. Render the StripeReturn component on that page — it polls the backend until the payment is confirmed (up to 18 seconds) and shows the result.
// pages/topup/return.tsx (or your router equivalent)
import { StripeReturn } from '@0gfoundation/0g-pay-sdk';
import { useNavigate } from 'react-router-dom';
export function TopupReturnPage({ sessionToken }: { sessionToken: string }) {
const navigate = useNavigate();
const sessionId = new URLSearchParams(window.location.search).get('session_id') ?? '';
if (!sessionId) return <p>Invalid return URL — missing session_id.</p>;
return (
<StripeReturn
sessionId={sessionId}
network="testnet"
sessionToken={sessionToken}
onConfirmed={(creditedUsd) => {
console.log(`$${creditedUsd.toFixed(2)} added to balance`);
navigate('/dashboard');
}}
/>
);
}
StripeReturn renders three states:
| State | Shown when |
|---|---|
| Spinner + "Confirming your payment…" | Polling in progress |
| Green "Payment confirmed! $X.XX added" | Backend confirms paid: true |
| Amber "Balance will update shortly" | 18-second timeout — payment likely succeeded but backend is slow |
If you need tighter control, use the hooks directly instead of the pre-built components.
useStripeCheckout — create a session and redirect programmatically:
import { useStripeCheckout } from '@0gfoundation/0g-pay-sdk';
function CustomTopupForm({ sessionToken }: { sessionToken: string }) {
const { createSession, loading, error } = useStripeCheckout({ network: 'testnet', sessionToken });
return (
<>
{error && <p style={{ color: 'red' }}>{error}</p>}
<button disabled={loading} onClick={() => createSession(50)}>
{loading ? 'Redirecting…' : 'Pay $50 with Card'}
</button>
</>
);
}
useStripeReturn — poll payment status and render your own UI:
import { useStripeReturn } from '@0gfoundation/0g-pay-sdk';
function MyReturnPage({ sessionId, sessionToken }) {
const { status, creditedMicros, availableMicrosAfter } = useStripeReturn(sessionId, {
network: 'testnet',
sessionToken,
});
if (status === 'loading') return <Spinner />;
if (status === 'timeout') return <p>Payment received — balance updating soon.</p>;
const creditedUsd = creditedMicros / 1_000_000;
return <p>✓ ${creditedUsd.toFixed(2)} credited to your account.</p>;
}
StripeClient is a headless client with no React or browser dependencies. Use it in Node.js servers, serverless functions, scripts, or any environment where you need to drive the 0G Pay API programmatically.
Requires Node.js 18+ (native fetch and AbortSignal.timeout).
// In a React project (react already installed):
import { StripeClient } from '@0gfoundation/0g-pay-sdk';
// In a pure Node.js project (no React needed):
import { StripeClient } from '@0gfoundation/0g-pay-sdk/server';
const client = new StripeClient({
network: 'testnet', // 'testnet' | 'mainnet' — defaults to 'testnet'
sessionToken: '...', // session token from exchangeSession()
});
Use baseUrl to point at your own proxy or a host other than the two network defaults:
const client = new StripeClient({
baseUrl: 'https://your-pay-backend.example.com', // overrides network
sessionToken: '...',
});
Exchange a Privy access token for a 0G Pay session token. The returned token is used as sessionToken for all other methods.
const { token, account_id } = await client.exchangeSession(privyAccessToken);
const { available_micros } = await client.getBalance();
const usd = available_micros / 1_000_000;
const { session_id, url } = await client.createCheckoutSession({
amountUsd: 50, // or use amountMicros: 50_000_000
successUrl: 'https://yourapp.com/return',
cancelUrl: 'https://yourapp.com/cancel',
email: 'user@example.com', // optional — pre-fills Stripe checkout
});
// url is the Stripe hosted checkout page — redirect the user there
amountUsd and amountMicros are mutually exclusive; exactly one must be provided.
import { StripeClient, StripeAuthError, StripePollingTimeoutError } from '@0gfoundation/0g-pay-sdk/server';
try {
const result = await client.pollCheckoutStatus(session_id, {
maxAttempts: 6, // default 6
intervalMs: 3_000, // default 3000 ms
});
if (result.paid) {
console.log(`Credited $${result.credited_micros / 1_000_000}`);
} else {
// All attempts returned paid: false — payment not yet confirmed.
// This is a normal result for reconcile calls (e.g. page load checks).
}
} catch (err) {
if (err instanceof StripeAuthError) {
// 401 or 403 — session token is invalid or expired. Refresh and retry.
console.error(`Auth failed (${err.status}): ${err.message}`);
} else if (err instanceof StripePollingTimeoutError) {
// Never received a clean response in maxAttempts tries (network/server error).
// err.cause holds the underlying error.
console.warn(`No response after ${err.attempts} attempts`, err.cause);
} else {
throw err;
}
}
Polling behaviour:
paid: true.paid: false, resolves with the last status — not an error. This makes single-attempt reconcile calls (maxAttempts: 1) work naturally.StripeAuthError immediately on 401/403 — no retries.StripePollingTimeoutError (with .cause) only when no clean HTTP response was received at all.const { topups, next_before } = await client.listTopups({ limit: 20 });
// paginate: pass next_before as `before` on the next call
const page2 = await client.listTopups({ limit: 20, before: next_before });
Pass source to filter by payment origin. This is important when checking whether a topup is a user's first real deposit — the backend may return credit-type rows before any Stripe or on-chain topup, causing a naïve "first row" check to misfire:
// Only stripe and on-chain topups — correct for "is first topup" checks
const { topups } = await client.listTopups({ limit: 1, source: 'stripe,chain' });
const isFirstTopup = topups.length === 0;
const { items, cursor } = await client.getUsage({ limit: 50 });
const page2 = await client.getUsage({ limit: 50, cursor });
Lists the account's credit lots — promotional bonuses, staking rewards and team grants — in spend order (soonest expiry first).
A page at a time, so walk it to the end before deriving any money figure from it:
const LIMIT = 500; // the backend's ceiling
const MAX_PAGES = 20; // a bound, not an expectation
// `extra` picks WHICH lots — leave it off to walk every lot, pass
// { state: 'granted' } for only the spendable ones. It deliberately cannot
// carry `limit` or `cursor`: the walk owns those, and a caller who lowered
// `limit` would make the completeness test below read a full page as short.
async function walkCreditLots(
extra: Pick<GetCreditsParams, 'state'> = {},
): Promise<{ lots: CreditLot[]; complete: boolean }> {
const lots: CreditLot[] = [];
let cursor: string | undefined;
for (let i = 0; i < MAX_PAGES; i++) {
const page = await client
.getCredits({ ...extra, limit: LIMIT, cursor })
.catch(() => null);
// Keep what arrived and call it partial. A 429 here is routine — this
// endpoint allows 20 requests a minute across every reader of it — and
// throwing would discard pages already in hand.
if (!page) return { lots, complete: false };
lots.push(...page.lots);
// The server's cursor is the only way on. Where none comes back, a short
// page is the end of the list and a full one is a list this deployment
// cannot let you finish.
if (!page.next_cursor) return { lots, complete: page.lots.length < LIMIT };
cursor = page.next_cursor;
}
return { lots, complete: false };
}
const { lots, complete } = await walkCreditLots();
if (!complete) {
// Do not derive a money figure from this. Show what you have and say it is partial.
}
for (const lot of lots) {
// `source` is the category to branch on, `display` is the label to render.
console.log(lot.source, lot.display, lot.state, lot.remaining_micros);
}
Never build a cursor yourself. It is tempting — pay-v2's cursor is
`${lot.expires_at}|${lot.lot_id}`, it calls the format "opaque by convention", and thecursorrequest parameter has always been forwarded. The reason not to is that you cannot tell which kind of deployment answered you, and one of them does not terminate.Reading
?cursorand reportingnext_cursorshipped in one pay-v2 commit, so a pay-v2 old enough to report no cursor also ignores the one you send — and hands back the same first page. A walk that synthesizes a cursor there appends that page forever. (The proxy in front can also report no cursor while passing?cursorthrough to a backend that does honour it, in which case a built cursor would have worked. Nothing in the response distinguishes the two, so guessing buys a working walk on one host and an infinite loop on the other.)That is what
completeis for. Where no cursor comes back, treat the list as unfinishable and say so — a partial list that admits it beats a figure that is quietly wrong.
Show state, never derive liveness yourself — it is computed against the request's own clock, so an expired-but-unswept lot reports 'expired' rather than looking spendable:
// Credit this account can actually spend right now.
// `state: 'granted'` makes this one request on any real account; the filter stays
// regardless — see below.
const walked = await walkCreditLots({ state: 'granted' });
const spendable = walked.complete
? walked.lots
.filter(lot => lot.state === 'granted' && lot.remaining_micros > 0)
.reduce((sum, lot) => sum + lot.remaining_micros, 0)
: null; // an incomplete list cannot produce this figure — say so, do not guess
state: 'granted'does not replace that filter. The backend's predicate for the parameter isstatus = 'active' AND expires_at > now, which — unlike the authoritative spendable sum — does not includeremaining_micros > 0. A drained-but-unswept lot therefore still comes back as'granted'with nothing left. Keeping the filter is also what makes the parameter safe to pass: a deployment that predates it ignores it silently, and with the filter in place that costs extra rows rather than a wrong figure.Pass it only where you want spendable lots. It is a filter on the list, so any figure that needs the dead lots must be walked without it —
available_micros, for instance, counts unswept expired credit, and a display that shows a user their clawed-back bonus needs the revoked lot to still arrive.Testing whether a deployment honours it can only ever come back "no". Ask for
'granted': if a lot arrives in another state, the parameter was ignored. If every lot is'granted', you have learnt nothing — an account whose lots happen to all be live looks identical either way. So never conclude "honoured, one request is enough" from a clean result; keep the walk.
Why the walk is not optional. Lots come back
expires_at ASC, and an'expired'lot's expiry is in the past by definition — so expired lots sort ahead of every live one and hold the page open.'spent'lots tend to sort early too, having drained in that order.'revoked'does not: a revocation is a refunded topup or a funder pulling credit back, and it leavesexpires_atalone, so a lot granted today for twelve months and revoked an hour later sorts at the end. The dead lots are therefore not a prefix you may stop scanning at, and a dead lot in the last row says nothing about whether the list is complete.Once an account holds more lots than
limit, the rows that fall off the end are the live credit. A credit sum from that page under-reports credit, and the usual deposit split —spendable_micros − Σ(credit), needed because Pay does not expose funds lots — over-reports deposit by exactly the same amount. That shows granted credit as the user's own money. pay-v2 hit this on staging on 2026-09-22 (#23).Once the walk is complete, that subtraction is exact and is the way to get a deposit figure.
Balance.spendable_microson its own cannot give you one — it is deposit plus credit, which is why the split exists.Which hosts honour what is a deployment question, not an SDK one. At the time of writing the testnet host reports
next_cursorand honoursstate; the mainnet host does neither. That will change without this README changing, so do not code against the sentence you just read — code againstcompleteand against thestatecheck above, and the walk stays correct on either.
Breaking change (backend, 2026-09-20).
CreditLot.statusis gone; usestate. The old field was the expiry sweep's bookkeeping column — it read"active"for up to an hour after credit had expired, and it could not tell an expiry from a revocation.stateanswers both, correctly.
import { StripeClient, StripeAuthError, StripePollingTimeoutError } from '@0gfoundation/0g-pay-sdk/server';
const PAY_URL = 'https://your-pay-backend.example.com';
// 1. Exchange Privy token once per user session
async function getClient(privyToken: string) {
const client = new StripeClient({ baseUrl: PAY_URL, sessionToken: '' });
const { token, account_id } = await client.exchangeSession(privyToken);
return new StripeClient({ baseUrl: PAY_URL, sessionToken: token, accountId: account_id });
}
// 2. Create a checkout session and return the Stripe URL to the frontend
async function createTopup(privyToken: string, amountUsd: number, returnUrl: string) {
const client = await getClient(privyToken);
const { session_id, url } = await client.createCheckoutSession({
amountUsd,
successUrl: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: returnUrl,
});
return { session_id, url };
}
// 3. After Stripe redirects back, confirm payment and read updated balance
async function confirmTopup(privyToken: string, sessionId: string) {
const client = await getClient(privyToken);
try {
const status = await client.pollCheckoutStatus(sessionId);
const balance = await client.getBalance();
return { status, balance };
} catch (err) {
if (err instanceof StripeAuthError) throw new Error('Session expired');
if (err instanceof StripePollingTimeoutError) return { timedOut: true };
throw err;
}
}
The Partner API is a set of public, read-only HTTP endpoints exposed by the 0G Pay portal. No authentication is required — call them directly with plain fetch.
Use getApiUrl() to resolve the correct base URL for each network:
import { getApiUrl } from '@0gfoundation/0g-pay-sdk';
const BASE = getApiUrl('testnet'); // 'https://0g-pay-scan.vercel.app'
A campaign is a funded reward pool. Partners query campaigns to display reward availability to users.
GET /api/partner/campaigns
const res = await fetch(`${BASE}/api/partner/campaigns`);
const data = await res.json(); // { campaigns: Campaign[] }
| Query param | Type | Description |
|---|---|---|
status | string | Filter by status: active | closed | expired | pending_approval | withdrawn |
limit | number | Max records to return |
GET /api/partner/campaigns/:id
const res = await fetch(`${BASE}/api/partner/campaigns/${campaignId}`);
const campaign = await res.json();
Returns the full campaign object including its associated lots (credit allocations).
Each lot reports state (granted | spent | expired | revoked), the same computed field as CreditLot.state. As of the 2026-09-20 backend release it no longer carries the raw status column, which lagged behind reality by up to an hour and showed expired credit as active.
GET /api/partner/campaigns/:id/rollup
const res = await fetch(`${BASE}/api/partner/campaigns/${campaignId}/rollup`);
const stats = await res.json();
Returns aggregated stats for the campaign — total granted, total remaining, user counts, etc.
A program defines the reward rules that govern how credits are calculated and distributed within a campaign. Programs expose their schedule, credit validity period, and two-tier rate configuration.
GET /api/partner/programs
const res = await fetch(`${BASE}/api/partner/programs`);
const data = await res.json(); // { programs: Program[] }
A sample response — two built-in programs, topup-promo (which carries terms) and staking (which does not):
{
"programs": [
{
"program": "topup-promo",
"running": true,
"starts_at": "2026-09-11T00:00:00Z",
"expires_at": "2026-09-26T23:59:59Z",
"credit_duration_secs": 300000000,
"live": true,
"terms": {
"first_topup": { "bonus_pct": 100, "cap_micros": 10000000000,
"definition": "bonus = the amount paid, capped at cap_micros" },
"later_topups": { "bonus_pct": 33.333333333333336, "divisor": 3,
"definition": "bonus = the amount paid / divisor, rounded down" }
}
},
{ "program": "staking", "running": true, "starts_at": null, "expires_at": null,
"credit_duration_secs": null, "live": true }
]
}
| Field | Type | Description |
|---|---|---|
program | string | Program identifier — 'topup-promo' or 'staking' |
running | boolean | Whether the program is switched on in the portal |
starts_at | string | null | ISO 8601 — when the program window opens |
expires_at | string | null | ISO 8601 — when the program window closes |
credit_duration_secs | number | null | How long each issued credit lot stays valid, in seconds |
live | boolean | Whether the offer is issuing credit right now — running and inside the window |
terms | object | undefined | The promotion's tier arithmetic. Present on topup-promo only |
Use live rather than comparing starts_at / expires_at yourself — the server folds its own clock into that field precisely so no caller has to, and running alone is not enough (a switched-on program outside its window issues nothing).
Each entry under terms carries:
| Field | Type | Description |
|---|---|---|
bonus_pct | number | The percentage to display. Derived server-side from the same rule the deposit path pays from, so no surface advertises a rate the ledger disagrees with. It is unrounded — 100 for the first tier, but 33.333333333333336 for a divisor of 3 — so round it for display |
cap_micros | number | undefined | First tier only — the ceiling on the bonus, in micro-USD |
divisor | number | undefined | Later tiers only — the topup is divided by this, floored |
definition | string | The rule in words, server-authored |
Pass account_id and the response gains your_next_topup — the authoritative answer to "is this user's next topup their first?". Prefer it over deriving first-ness from the topup list:
GET /api/partner/programs?account_id=<32-hex account id>
const res = await fetch(`${BASE}/api/partner/programs?account_id=${accountId}`);
const { programs, your_next_topup } = await res.json();
// your_next_topup: { tier: 'first' | 'later' | '', terms?: Tier, as_of: string }
// `terms` is absent whenever `tier` is '' — nothing is on offer, so there is nothing to describe.
if (your_next_topup.tier === 'first' && your_next_topup.terms) {
console.log(`First topup — ${Math.round(your_next_topup.terms.bonus_pct)}% back`);
}
tier is '' when nothing is on offer (the promotion is not live). as_of is the server timestamp the answer was computed at.
type Tier = { bonus_pct: number; cap_micros?: number; divisor?: number };
type Terms = { first_topup: Tier; later_topups: Tier };
// Dispatch on WHICH tier you are pricing, never on which fields happen to be present:
// `cap_micros` belongs to the first tier and `divisor` to later ones today, but a tier
// carrying both would silently take the wrong branch.
function computeBonus(amountMicros: number, terms: Terms, tier: 'first' | 'later'): number {
if (tier === 'first') {
// 100% of what was paid, capped. Deliberately not derived from bonus_pct: the backend
// pins the first tier to the constant `ops.FirstTopupBonusPct` (0g-pay-v2,
// internal/ops/promo.go) and writes the payout as "the whole paid amount" rather than
// amount*pct/100, because at 100% the multiply is a no-op that risks overflow. The
// percentage is published FROM that constant, so it is what you SAY, never what you
// recompute from.
return Math.min(amountMicros, terms.first_topup.cap_micros ?? amountMicros);
}
// Later tiers: integer division IS the rounding rule — floor, never round.
const { divisor } = terms.later_topups;
return divisor ? Math.floor(amountMicros / divisor) : 0;
}
const promo = programs.find(p => p.program === 'topup-promo');
// `terms` rides on topup-promo, but guard it: a program row with no configured
// arithmetic comes back without the key.
const bonus = promo?.live && promo.terms && your_next_topup?.tier
? computeBonus(50_000_000, promo.terms, your_next_topup.tier)
: 0;
your_next_topup comes from the ?account_id= request form above — a call without it returns programs alone.
Show bonus_pct in copy, but compute the figure from cap_micros / divisor — the percentage is what you say, not what the deposit path pays from. Round it before displaying: the later tier's is a true division, so 33.333333333333336 is what arrives on the wire.
your_next_topup.tier above is the direct answer and needs no derivation. If you do want to inspect the history, call StripeClient.listTopups with source: 'stripe,chain' to exclude service-recorded rows that would otherwise produce a false "no topups yet":
const { topups } = await client.listTopups({ limit: 1, source: 'stripe,chain' });
const isFirstTopup = topups.length === 0;
Each row also carries bonus_micros and tier, so a past topup can report what it actually earned without re-deriving it.
getApiUrl(network?)Returns the base URL for the given network. Use this as the prefix for Partner API calls.
import { getApiUrl } from '@0gfoundation/0g-pay-sdk';
getApiUrl('testnet') // 'https://0g-pay-scan.vercel.app'
getApiUrl('mainnet') // 'https://pay.0g.ai'
Both are returned without a trailing slash — append paths as /api/v2/....
getSessionUrl(network?)Returns the session exchange endpoint for the given network.
getSessionUrl('testnet') // 'https://0g-pay-scan.vercel.app/api/v2/session'
getSessionUrl('mainnet') // 'https://pay.0g.ai/api/v2/session'
<OGPay>| Prop | Type | Default | Description |
|---|---|---|---|
provider | EIP1193Provider | — | Required. EIP-1193 wallet provider (window.ethereum, wagmi, viem, …) |
children | ReactNode | <button>Deposit</button> | Custom trigger element |
methods | string[] | ['crypto', 'card'] | Payment methods shown in the TokenFlight widget |
outputAmount | string | — | Pre-fill the output amount in the widget |
lockFromToken | boolean | false | Lock the source token to the native token |
tradeType | string | 'EXACT_INPUT' | Trade type passed to the TokenFlight widget |
logo | ReactNode | 0G logo | Custom logo in the modal header |
onSuccess | (data) => void | — | Called when a TokenFlight swap completes successfully |
onError | (data) => void | — | Called when a TokenFlight swap fails |
mode | 'user' | 'developer' | 'user' | 'user': fixed 0G recipient. 'developer': use recipientAddress |
recipientAddress | string | — | Required when mode="developer". Must be a valid 0x… Ethereum address |
stripe | StripeConfig | — | When provided, adds a Card (Stripe) tab for USD top-up via Stripe |
<StripeCheckoutForm>Renders the amount picker UI standalone — useful if you want it outside the modal.
import { StripeCheckoutForm } from '@0gfoundation/0g-pay-sdk';
<StripeCheckoutForm
config={{ network: 'testnet', sessionToken, email, successUrl, cancelUrl }}
/>
| Prop | Type | Description |
|---|---|---|
config | StripeConfig | Required. Stripe configuration object |
<StripeReturn>Polls the backend and renders the payment confirmation UI.
import { StripeReturn } from '@0gfoundation/0g-pay-sdk';
<StripeReturn
sessionId={sessionId}
network="testnet"
sessionToken={sessionToken}
onConfirmed={(creditedUsd) => { /* navigate away */ }}
/>
| Prop | Type | Description |
|---|---|---|
sessionId | string | Required. The session_id query param from Stripe's redirect |
network | Network | 'testnet' | 'mainnet' — defaults to 'testnet' |
sessionToken | string | Required. Session token from the Pay backend |
onConfirmed | (creditedUsd: number) => void | Called once when the backend confirms payment. Receives the credited amount in USD |
useStripeCheckout(config)const { createSession, loading, error } = useStripeCheckout(config: StripeConfig);
| Return | Type | Description |
|---|---|---|
createSession | (amountUsd: number) => Promise<void> | Creates a Stripe checkout session and redirects window.location.href to the Stripe URL |
loading | boolean | true while the API call is in flight |
error | string | null | Error message if the API call failed, null otherwise |
useStripeReturn(sessionId, config)const { status, creditedMicros, availableMicrosAfter } = useStripeReturn(sessionId, {
network: 'testnet',
sessionToken,
});
| Return | Type | Description |
|---|---|---|
status | 'loading' | 'confirmed' | 'timeout' | Current polling state |
creditedMicros | number | Amount credited, in micro-USD (divide by 1,000,000 for USD). Non-zero when status === 'confirmed' |
availableMicrosAfter | number | Account balance after crediting, in micro-USD |
Polls GET /api/v2/topup/checkout/{sessionId} up to 6 times with 3-second intervals (18-second ceiling). Stops immediately on paid: true.
StripeClientconst client = new StripeClient(config: StripeClientConfig);
| Method | Signature | Description |
|---|---|---|
exchangeSession | (privyToken: string) => Promise<SessionInfo> | Exchange a Privy access token for a 0G Pay session token. |
getBalance | () => Promise<Balance> | Fetch the current account balance. |
createCheckoutSession | (params: CreateCheckoutSessionParams) => Promise<CheckoutSession> | Create a Stripe Checkout session. Returns { session_id, url } — does not redirect. |
pollCheckoutStatus | (sessionId: string, options?: PollCheckoutStatusOptions) => Promise<CheckoutStatus> | Poll for payment confirmation. See polling behaviour below. |
listTopups | (params?: ListTopupsParams) => Promise<ListTopupsResult> | List topup history, newest first. |
getUsage | (params?: GetUsageParams) => Promise<GetUsageResult> | Fetch usage history. |
getCredits | (params?: GetCreditsParams) => Promise<GetCreditsResult> | List one page of the account's credit lots. Page with cursor — see Get credits. |
CreateCheckoutSessionParams
| Field | Type | Description |
|---|---|---|
amountUsd | number | Amount in USD (e.g. 50 for $50). Mutually exclusive with amountMicros. |
amountMicros | number | Amount in micro-USD (e.g. 50_000_000 for $50). Mutually exclusive with amountUsd. |
successUrl | string | Stripe redirects here after payment. Do not URL-encode {CHECKOUT_SESSION_ID}. |
cancelUrl | string | Stripe redirects here on cancel. |
email | string | Pre-fill the email field in Stripe checkout. |
PollCheckoutStatusOptions
| Field | Type | Default | Description |
|---|---|---|---|
maxAttempts | number | 6 | Maximum poll attempts. |
intervalMs | number | 3000 | Milliseconds between attempts. |
ListTopupsParams
| Field | Type | Description |
|---|---|---|
limit | number | Max number of records to return. |
before | number | Pagination cursor — use next_before from the previous page. |
source | string | Filter by topup source. Use 'stripe,chain' to exclude credit-type rows when checking first-topup status. |
GetUsageParams
| Field | Type | Description |
|---|---|---|
limit | number | Max number of records to return. |
cursor | string | Pagination cursor — use cursor from the previous page. |
GetCreditsParams
| Field | Type | Description |
|---|---|---|
limit | number | Lots per page. Backend default 100, ceiling 500; a value outside 1..500 is ignored and you get 100. |
cursor | string | Where to resume — pass back the previous page's next_cursor, and nothing else. A cursor you build yourself is ignored by exactly the deployments that need it. |
state | 'granted' | Ask the backend for only the lots spendable right now. A deployment that predates it ignores it silently — see Get credits. |
GetCreditsResult
| Field | Type | Description |
|---|---|---|
lots | CreditLot[] | One page, in spend order (expires_at ASC). |
next_cursor | string | null | undefined | A string means more may follow — set on any full page, so not proof. null or absent means no further page is reachable: with a short page that is the end of the list, with a full page it means this deployment cannot page and your list is partial. Never build a cursor yourself — see Get credits. |
The list is paged, and a full page is never proof of the end. Never derive a money figure from a single call — Get credits has the walk.
StripeAuthErrorThrown immediately on 401/403 responses — no retries are made.
class StripeAuthError extends Error {
status: number; // 401 or 403
}
Thrown by: exchangeSession, getBalance, createCheckoutSession, pollCheckoutStatus, listTopups, getUsage, getCredits.
StripePollingTimeoutErrorThrown by pollCheckoutStatus when maxAttempts is exhausted and no clean HTTP response was ever received (i.e. all attempts failed due to network or server errors).
Note: If the backend responded with
paid: falseon every attempt,pollCheckoutStatusresolves (returns the last status) instead of throwing. This lets single-attempt reconcile calls work naturally.
class StripePollingTimeoutError extends Error {
sessionId: string; // the session ID being polled
attempts: number; // how many attempts were made
cause?: unknown; // the underlying fetch/HTTP error from the last attempt
}
import type {
// React components & hooks
StripeConfig,
OGPayProps,
EIP1193Provider,
// StripeClient
StripeClientConfig,
CreateCheckoutSessionParams,
PollCheckoutStatusOptions,
ListTopupsParams,
ListTopupsResult,
GetUsageParams,
GetUsageResult,
GetCreditsParams,
GetCreditsResult,
SessionInfo,
Balance,
Topup,
UsageItem,
CreditLot,
// Shared
CheckoutSession,
CheckoutStatus,
Network,
} from '@0gfoundation/0g-pay-sdk';
// or from '@0gfoundation/0g-pay-sdk/server' for Node.js-only projects
StripeConfigUsed by the React components (OGPay, StripeCheckoutForm) and hooks.
interface StripeConfig {
/** 'testnet' | 'mainnet' — defaults to 'testnet'. */
network?: Network;
/** Session token from POST getSessionUrl(network) */
sessionToken: string;
/** Optional email pre-filled in Stripe checkout */
email?: string;
/** URL Stripe redirects to after payment. Defaults to {origin}/topup/return */
successUrl?: string;
/** URL Stripe redirects to on cancel. Defaults to the current page */
cancelUrl?: string;
}
StripeClientConfigUsed by StripeClient (Node.js / server-side).
interface StripeClientConfig {
/** 'testnet' | 'mainnet' — defaults to 'testnet'. */
network?: Network;
/**
* Explicit backend URL — takes precedence over `network`.
* Use for self-hosted proxies or direct pay-backend access.
*/
baseUrl?: string;
/** Session token from exchangeSession() */
sessionToken: string;
}
SessionInfointerface SessionInfo {
token: string; // 0G Pay session token — use as sessionToken
account_id: string;
}
Balanceinterface Balance {
available_micros: number; // the account's TOTAL, every lot, whatever service it was granted for
spendable_micros?: number | null; // what the CALLER may actually draw — gate on this one
debt_micros: number;
net_micros: number;
serve: string | null; // "allow" = good standing; other values = service paused
display: { message?: string; [key: string]: unknown } | null;
as_of: string | null; // ISO 8601 timestamp of the balance snapshot
stop_reason: string | null;
}
serve and stop_reason are derived from spendable_micros, not from available_micros. A null spendable_micros means the field was absent from the response — treat that as untrustworthy, not as zero.
Topupinterface Topup {
id: number; // pass as `before` to page past this row
source: string; // e.g. "stripe" | "chain"
amount_micros: number;
reversed_micros: number;
status: 'credited' | 'partially_reversed' | 'reversed';
created_at: string; // ISO 8601
reference: string; // Stripe checkout session ID when source === "stripe"
bonus_micros: number; // what the promotion granted for this topup; 0 when nothing
tier: 'first' | 'later' | null; // null = the promotion had no view on it, NOT "later"
}
UsageIteminterface UsageItem {
service: string;
event_id: string;
charge_batch_id: string;
sku: string;
unit: string;
quantity_micros: number;
rate_micros: number;
amount_micros: number;
occurred_at: string; // ISO 8601
}
CreditLotinterface CreditLot {
lot_id: string;
remaining_micros: number;
granted_micros: number;
expires_at: string; // ISO 8601; never null
service?: string | null; // null = account-wide; otherwise the service it is scoped to
source: 'promotion' | 'staking' | 'team'; // the category to branch on
state: 'granted' | 'spent' | 'expired' | 'revoked'; // what became of it — the field to show
display?: string | null; // the label to render
depth?: number;
parent_lot_id?: string | null;
}
source | Means |
|---|---|
promotion | The top-up bonus |
staking | The $iAI staking reward |
team | A campaign grant |
state | Means |
|---|---|
granted | Active and spendable |
spent | Drained to zero |
expired | Past its expiry — including active-but-unswept, which the user cannot spend either way |
revoked | Cancelled: a refunded topup, or a funder pulling the credit back |
display is the label: the campaign's own name for team credit, and the product's words for the two built-in programs ("Top-up bonus", "Staking reward"). Branch on source, render display — do not derive one from the other. A revoked reward stays in the list rather than disappearing, so a user whose refund clawed back a bonus can see that happen — which is why that view must be walked without state: 'granted', since the parameter filters exactly those lots out.
CheckoutSessioninterface CheckoutSession {
session_id: string; // Stripe checkout session ID (cs_...)
url: string; // Stripe hosted checkout URL
}
CheckoutStatusinterface CheckoutStatus {
paid: boolean;
credited_micros: number; // Amount credited in micro-USD
available_micros_after: number; // Balance after credit in micro-USD
replayed: boolean;
}
EIP1193Providerinterface EIP1193Provider {
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
}
Compatible with window.ethereum, wagmi's useWalletClient, viem's wallet client, and any EIP-1193 provider.
All internal amounts use micro-USD: 1 USD = 1,000,000 micros.
const usd = micros / 1_000_000;
const micros = Math.round(usd * 1_000_000);
StripeClient.createCheckoutSession accepts either amountUsd (float dollars) or amountMicros (integer micros). Use amountMicros when your system already works in integer cents or micros to avoid floating-point conversion.
wagmi v2
import { useWalletClient } from 'wagmi';
import { OGPay } from '@0gfoundation/0g-pay-sdk';
export function DepositButton() {
const { data: walletClient } = useWalletClient();
if (!walletClient) return null;
return <OGPay provider={walletClient} />;
}
viem
import { createWalletClient, custom } from 'viem';
import { OGPay } from '@0gfoundation/0g-pay-sdk';
const walletClient = createWalletClient({ transport: custom(window.ethereum) });
<OGPay provider={walletClient} />;
window.ethereum (MetaMask)
<OGPay provider={window.ethereum} />
Set mode="developer" and supply recipientAddress to route TokenFlight deposits to your own address instead of the 0G recipient.
<OGPay
provider={provider}
mode="developer"
recipientAddress="0xYourAddress"
/>
The SDK throws on render if mode="developer" is set without a valid 0x-prefixed 40-character hex address.
Pass any element as children to use it as the trigger instead of the default Deposit button.
<OGPay provider={provider}>
<button className="my-custom-btn">Top Up</button>
</OGPay>
<OGPay provider={provider}>
<img src="/pay-icon.svg" alt="Pay" style={{ cursor: 'pointer' }} />
</OGPay>
MIT © 0G Labs
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 0.4.0Registry latest | 30 Sept 2026 | 13 | |
| 0.3.1-beta.12 | 25 Sept 2026 | 13 | |
| 0.3.1-beta.11 | 24 Sept 2026 | 13 | |
| 0.3.1-beta.10 | 24 Sept 2026 | 13 | |
| 0.3.1-beta.9 | 23 Sept 2026 | 13 | |
| 0.3.1-beta.8 | 21 Sept 2026 | 13 | |
| 0.3.1-beta.7 | 11 Sept 2026 | 13 | |
| 0.3.1-beta.6 | 11 Sept 2026 | 13 | |
| 0.3.1-beta.5 | 11 Sept 2026 | 13 | |
| 0.3.1-beta.4 | 11 Sept 2026 | 13 | |
| 0.3.1-beta.3 | 9 Sept 2026 | 13 | |
| 0.3.1-beta.2 | 9 Sept 2026 | 13 | |
| 0.3.1-beta.1 | 8 Sept 2026 | 13 | |
| 0.3.1-beta.0 | 8 Sept 2026 | 13 | |
| 0.2.1 | 2 Jul 2026 | 13 | |
| 0.2.0 | 18 Jun 2026 | 13 | |
| 0.1.3 | 8 Jun 2026 | 13 | |
| 0.1.2 | 29 Apr 2026 | 13 | |
| 0.1.1 | 27 Apr 2026 | 13 | |
| 0.1.0 | 27 Apr 2026 | 13 |
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 22:11 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:f35d82efe5a581444b2193d2e5c07819649b9318d792afebfd4cfe5dc3ec8274