AppWallet docs
Metered billing for AI apps. Your users get a wallet; every request costs what it actually costs; when they run out of money, a top-up flow appears — not your error handling.
Quickstart — five minutes
Every snippet below is complete. No elisions, no pseudo-code.
Files in a fresh integration: package.json (add the SDK), one env var, three code touches.
1. Create an app, copy the secret key
Sign up at app.appwallet.io, create your app, and copy the secret key
(sk_live_… or sk_test_…). Never put it in client code; it is a server credential.
2. Install the SDK
shellnpm install @appwallet/node
3. Env var
shellexport APPWALLET_SECRET_KEY=sk_live_…
4. Upsert a wallet on every login, and use it everywhere
server/wallet.ts — complete fileimport { AppWallet } from "@appwallet/node";
const aw = new AppWallet({ apiKey: process.env.APPWALLET_SECRET_KEY! });
export { aw };
// Call on every login. POST /v1/wallets is an upsert on (app, external_user_id);
// it is safe to repeat and never duplicates a wallet.
export async function getOrCreateWallet(userId: string, email?: string) {
return aw.wallets.upsert({ externalUserId: userId, email });
}
5. Point the OpenAI SDK at AppWallet and name the user
Requests flow through the AppWallet gateway, which meters, prices, reserves and settles per call — exactly one header is added, carrying your user id:
server/ai.ts — complete fileimport OpenAI from "openai";
import { InsufficientFundsError } from "@appwallet/node";
import { aw, getOrCreateWallet } from "./wallet.js";
// The base URL changes; nothing else does.
const openai = new OpenAI({
apiKey: process.env.APPWALLET_SECRET_KEY!,
baseURL: "https://api.appwallet.io/v1",
});
export async function completeFor(userId: string, prompt: string) {
const wallet = await getOrCreateWallet(userId); // ← YOUR user id here
try {
const res = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [{ role: "user", content: prompt }],
}, { headers: { "X-AppWallet-User": userId } }); // ← the one extra header
return res.choices[0]?.message?.content ?? "";
} catch (error) {
if (error instanceof InsufficientFundsError) {
// The 402 tells you the wallet ran dry, but only YOUR server can mint a
// hosted top-up link (it holds the secret key). Mint one for this
// wallet and send the user there; when they come back, the balance is
// ready.
const { url } = await aw.wallets.createPortalSession(wallet.id);
throw new Error(`Out of funds — top up at ${url}`);
}
throw error;
}
}
baseURL change
plus one header is the AppWallet integration. The wallet view the user tops up
through is a link your server mints with
wallets.createPortalSession(wallet.id) — never the raw
top_up_url from a 402, which names the wallet but is not itself an
authorised link.6. Drop in a top-up link
server/topup.ts — complete fileimport { aw } from "./wallet.js";
// Any surface that needs a hosted top-up link — an API route, a button
// handler, a low-balance email — mints one on demand:
export async function createTopUpLink(walletId: string) {
const { url } = await aw.wallets.createPortalSession(walletId);
return url; // 15-minute single-use hosted wallet
}
Streaming from a browser (session tokens)
Secret keys never go in a browser. For streaming straight from a browser page,
the backend mints a short-lived session token
(awt_…) bound to that user's wallet with a strict spend cap; the
browser calls AppWallet with that.
Server: mint a capped token
app/api/wallet-token/route.ts — complete file (Next.js)import { AppWallet } from "@appwallet/node";
const aw = new AppWallet({ apiKey: process.env.APPWALLET_SECRET_KEY! });
export async function GET() {
// authenticate your user first — userId is YOUR session's user.
const userId = currentUserId();
const wallet = await aw.wallets.upsert({ externalUserId: userId });
const { token, expires_at } = await aw.wallets.createSessionToken(wallet.id, {
ttlSeconds: 900, // 15 minutes, ≤ 3600
spendCapNanos: 2_000_000_000n, // this session can spend at most $2
modelAllowlist: [], // [] = every model your app may use
});
return Response.json({ token, expires_at });
}
function currentUserId(): string {
// Your real auth: cookie/JWT/whatever. Never trust a client-supplied
// wallet id — always derive the user server-side.
throw new Error("wire your auth here");
}
Browser: stream with the session token
client/stream.ts — complete fileexport async function streamCompletion(token: string, prompt: string): Promise {
// Your backend route above supplies the token on demand.
const minted = await (await fetch("/api/wallet-token")).json() as { token: string };
const res = await fetch("https://api.appwallet.io/v1/chat/completions", {
method: "POST",
headers: {
authorization: `Bearer ${minted.token}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
stream: true,
messages: [{ role: "user", content: prompt }],
}),
});
if (!res.ok) throw new Error(`gateway ${res.status}`);
// SSE parse — failure events surface as thrown errors (docs/05 §4).
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "", out = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (;;) {
const nl = buffer.indexOf("\n\n");
if (nl < 0) break;
const raw = buffer.slice(0, nl); buffer = buffer.slice(nl + 2);
if (!raw.startsWith("data: ")) continue;
const payload = raw.slice(6);
if (payload === "[DONE]") return out;
const chunk = JSON.parse(payload) as {
choices: { delta: { content?: string } }[];
};
out += chunk.choices[0]?.delta?.content ?? "";
}
}
return out;
}
Origin
header, and session tokens only work from origins the app owner registered in
the console (Settings → allowed_origins). Register
https://your-app.example before shipping browser streaming; a
leaked token stopped from another page returns
403 origin_not_allowed. Server-to-server calls send no Origin and
are unaffected.The hosted wallet
POST /v1/wallets/{id}/portal-sessions returns a 15-minute
single-use URL to the hosted wallet screen: balance, 30-day spend history, top
up through Stripe. Possession of the URL is the authorisation — that is why it
is short and single-use. The SDK wraps it:
snippetconst { url, expires_at } = await aw.wallets.createPortalSession(wallet.id);
// redirect your user to `url`
Reuse within one browser session is fine: opening the wallet once keeps the browsing session armed until the token expires — only the /portal/start consume is single-use. A stale or reused link shows the app's own error copy, never a half page.
Custom meters
Non-token billing (per PDF, per seat, per search) is fully covered. Define a meter once in the console (or through the SDK), then charge on events:
server/meters.ts — complete fileimport { AppWallet } from "@appwallet/node";
const aw = new AppWallet({ apiKey: process.env.APPWALLET_SECRET_KEY! });
// Create (or update) a meter: what one unit costs. id per app+key.
await aw.meters.create({
key: "pdf_render",
display_name: "PDF render",
unit_price_nanos: "500000000", // $0.50 per unit, decimal string
unit_label: "page",
});
// Charge on an event. Quantity is a positive integer.
await aw.wallets.charge(wallet.id, {
meterKey: "pdf_render",
quantity: 3n,
metadata: { doc: "quarterly-report" },
}, { idempotencyKey: `upload_${documentId}` }); // ← your event id: retries are safe
Webhooks
For deposit events, balance-low warnings, freezes, and more, the AppWallet posts to your endpoint, signed with the endpoint's own secret.
server/webhook.ts — complete file (Next.js)import { verifyWebhook, WebhookError } from "@appwallet/node";
import { text } from "node:stream/consumers";
export async function POST(req: Request) {
// VERIFY FIRST, parse second. Raw body must be byte-for-byte the signed one;
// every framework makes a string somewhere safe to pull it from.
const raw = await text(req.body!);
const sig = req.headers.get("AppWallet-Signature") ?? "";
let event: { type: string; data: unknown };
try {
event = verifyWebhook(raw, sig, process.env.APPWALLET_WEBHOOK_SECRET!);
} catch {
return new Response("bad signature", { status: 400 });
}
switch (event.type) {
case "deposit.succeeded": {
// money moved; the balance in your tables is now stale.
break;
}
case "wallet.balance_low":
break; // nudge the user? feature your call — emails are yours
case "wallet.frozen":
break; // dispute/pause. Credit flow stops on your side.
default:
break; // unknown events: not an error; log and carry on
}
return Response.json({ ok: true });
}
Delivery contract: 8 retries, exponential backoff up to 24 hours, then a visible failure in the developer console. Event catalog:
| Event | Fires when |
|---|---|
deposit.succeeded / deposit.failed | Stripe settles or declines |
wallet.balance_low | Crosses the app's threshold (debounced, ≤ hourly) |
wallet.frozen | Freeze, automatic or by the developer |
usage.settled | Every settled request. High volume — opt-in only |
auto_reload.succeeded / auto_reload.failed | |
dispute.opened |
Error handling
Typed errors — catch what you care about and let the rest be a broken request:
snippetimport {
AppWalletError, InsufficientFundsError, WalletFrozenError,
RateLimitedError, IdempotencyKeyReusedError,
} from "@appwallet/node";
try {
await aw.wallets.charge(wallet.id, { meterKey: "pdf_render", quantity: 1n });
} catch (error) {
if (error instanceof InsufficientFundsError) {
const { url } = await aw.wallets.createPortalSession(wallet.id);
redirect(url); // hosted top-up for THIS user
} else if (error instanceof WalletFrozenError) {
showFrozenNotice(error.frozenReason); // e.g. "dispute_review"
} else if (error instanceof RateLimitedError) {
// already retried the SDK budget; you now genuinely back off too
} else if (error instanceof IdempotencyKeyReusedError) {
logBug("same idempotency key, different body — a bug in my code");
} else if (error instanceof AppWalletError) {
support(error.requestId); // always carry request_id
}
}
5xx and 429 retry automatically (with Retry-After respected;
never 4xx). Every error carries requestId, which control/edge
responses echo and support conversations quote back.
Browser: the insufficient-funds boundary
As your user does whatever your app does, funds can run out mid-task — a 402
from any call. <InsufficientFundsBoundary> from
@appwallet/react turns it into a top-up prompt in place, no
handling code elsewhere needed. The browser cannot mint a portal link (your
secret key never leaves the server), so the boundary takes a
mintTopUpUrl callback that asks your backend for a fresh
one — the caught 402's raw link is only the fallback display:
app/api/topup-link/route.ts — complete file (Next.js)import { aw, getOrCreateWallet } from "../../../server/wallet";
export async function POST(req: Request) {
// authenticate your user first — never trust a client-supplied wallet id
const userId = currentUserId();
const wallet = await getOrCreateWallet(userId);
const { url, expires_at } = await aw.wallets.createPortalSession(wallet.id);
return Response.json({ url, expires_at });
}
function currentUserId(): string {
throw new Error("wire your auth here");
}
app/your-feature/page.tsx — complete file"use client";
import { AppWalletProvider, InsufficientFundsBoundary, WalletBalance, TopUpButton } from "@appwallet/react";
export default function Page() {
// token: the awt_ session token minted server-side (see Streaming above).
const token = getTokenFromServerSide();
return (
<AppWalletProvider token={token} apiBase="https://api.appwallet.io">
<InsufficientFundsBoundary
mintTopUpUrl={() => fetch("/api/topup-link", { method: "POST" })
.then((r) => r.json() as Promise<{ url: string }>)
.then((j) => j.url)}>
<p><WalletBalance /></p>
<p><TopUpButton amounts={[10, 25, 50]} /></p>
<YourFeature /> {/* 402s anywhere inside become a top-up UI */}
</InsufficientFundsBoundary>
</AppWalletProvider>
);
}
Going live
- Keys: run test mode on
sk_test_…; there is no branching in the code — the key decides. Rotate by minting a second key in console, switching over, revoking the old key — the old key never stops mid-flight. - Origins: browser session-token calls require
allowed_originsin the console settings. Include scheme + host exactly, e.g.https://your-app.example. - Caps: per-app daily/monthly caps protect your users. Default caps applied to new wallets come from Settings.
- Prices for end users: set
show_prices_to_end_usersto control whether the wallet shows per-request costs (D18) — off is a fine default. - Backups/reconciliation are the platform's job — you watch your console's usage and margin page (or a webhook) if you want.
Test your integration
Before shipping, five minutes proves the whole pipe:
- Sign up at
app.appwallet.io, create an app, copy the key. export APPWALLET_SECRET_KEY=sk_test_…,- Swap an existing OpenAI call's
baseURLand add the user header; make one completion request, expecting a 402 (nothing loaded yet). - Call
wallets.upsertfor that user and thenwallets.createCheckoutSession(or click through the portal URL) to add $10 in test mode. - Repeat the completion, then check
GET /v1/wallets/{id}: the balance decreased (you can read it in the SDK or in the hosted wallet — top up again, spend again).
Stuck? Every failure is a typed error with its requestId — the
grep for the log by that id. If you can't tell why a machine-based problem is
there, the URL is in the error and you can ask.