Developer Reference
Docs
Add Sign in with Chabi to your app and run its AI on your users' plans.
Updated October 2026
Synopsis
// Your existing OpenAI code. Two changes.const client = new OpenAI({baseURL: "https://chabi.dev/v1",apiKey: userToken, // from Sign in with Chabi});
Claude models work the same way with the Anthropic SDK.
AI prompt
Building with an AI coding tool, like Cursor, Claude Code, Lovable or Bolt? Copy this prompt into it, fill in your client ID and redirect URI from the developer portal, and it adds sign-in, requests and error handling for you.
Add "Sign in with Chabi" to this app, so its AI features run on each user's own Chabi plan instead of my API key. Chabi is compatible with the OpenAI and Anthropic APIs. Full docs: https://chabi.dev/docs
My Chabi client ID: <paste it from https://chabi.dev/developers/portal>
My redirect URI: <the URL users return to after signing in, e.g. https://myapp.com/callback; it must be registered in the portal, and add http://localhost:<port>/callback there for development>
1. Sign-in: OAuth 2.0 authorization code flow with PKCE. There is no client secret.
- Make a random code_verifier (43-128 characters, base64url) and a random state. Save them in localStorage (not sessionStorage), keyed by state, with the time, so the return works even in another tab (email sign-in links often open one).
- Send the user to https://chabi.dev/oauth/authorize with response_type=code, client_id, redirect_uri, code_challenge = base64url(SHA-256(code_verifier)), code_challenge_method=S256 and state.
- When they come back to the redirect URI: if the URL has error=access_denied, they declined, so say so kindly. Otherwise look up the verifier by state and delete that entry so it can't be used twice (no match, or older than 15 minutes, means the sign-in didn't start here: start again; never trust a missing state), remove ?code from the address bar, and POST form-encoded to https://chabi.dev/oauth/token with grant_type=authorization_code, code, redirect_uri, client_id and code_verifier.
- The response has access_token, refresh_token and expires_in (seconds). Save all three, with the expiry time.
- Stay signed in: shortly before the token expires, or when a request fails with 401 and code invalid_token, POST grant_type=refresh_token, refresh_token and client_id to the same endpoint. Each refresh token works once, so always save the new one. Using one twice ends the sign-in (it looks like a stolen token): its access token stops working too, so only one refresh may run at a time across every open tab, not just within one page. In the browser, wrap it in navigator.locks.request("chabi-refresh", ...) and, inside the lock, reread the saved tokens first: if the saved refresh token is no longer the one this tab was about to use, another tab already refreshed, so use its tokens instead (the docs have a copyable example). If refreshing fails (invalid_grant), sign the user in again.
- Add a "Sign in with Chabi" button for signed-out users and a sign-out that clears the tokens.
- If this is a desktop app: use http://127.0.0.1:<any port>/callback or the app's own URL scheme as the redirect URI, and keep the tokens in the OS keychain.
2. AI requests: wherever the app calls OpenAI or Anthropic, use the signed-in user's access_token as the API key.
- GPT models with the official OpenAI SDK: baseURL "https://chabi.dev/v1", apiKey = access_token. Models: gpt-6-astra, gpt-6.1-sol, gpt-6-luna, gpt-5.6-sol, chat-latest. Use max_completion_tokens.
- Claude models with the official Anthropic SDK: baseURL "https://chabi.dev", apiKey = access_token. Models: claude-fable-5-1, claude-opus-5-5, claude-sonnet-5-5, claude-haiku-5-5. max_tokens is required.
- These models reason before answering, and that counts toward the output cap: leave room (e.g. 4096), or replies come back cut off or empty.
- For JSON output, use each provider's own structured-output option, which Chabi passes through: OpenAI response_format: { type: "json_schema", json_schema: { name, schema, strict: true } }; Anthropic output_config: { format: { type: "json_schema", schema } } (alongside effort). Still check the parsed result in code.
- Credits are charged by actual usage, so the cost of a feature varies. Don't promise a fixed price per click in the UI.
- In the browser, set dangerouslyAllowBrowser: true. It's safe here: the token only spends that user's credits in this app.
- Keep the app's current model if it's in the lists above (https://chabi.dev/v1/models has the current list). Otherwise ask me which to use. Chabi never swaps models.
- Remove my own OpenAI or Anthropic API key from any code that runs in the browser or app. If a server route only existed to hide that key, tell me and ask before deleting it.
3. Errors Chabi adds:
- 402 insufficient_credits: the user doesn't have enough credits for this request. Chabi holds the request's most possible cost (input plus the full output cap) up front, so this can happen while the balance is still above zero; a smaller max_tokens may fit. Show a friendly message linking to upgrade_url from the error body. (The Anthropic SDK nests error details one level deeper, in err.error.error.)
- 401: invalid_token means refresh as above; grant_revoked or token_revoked means the user removed access, so sign them in again.
- 429: too many requests. Wait the seconds in the Retry-After header, then retry.
4. Nice to have: show the user's remaining credits from GET https://chabi.dev/v1/credits (balance_credits), and which account is signed in from GET https://chabi.dev/v1/me (name, which can be null). Send the access token as a Bearer token.
Nothing secret goes in the app: Chabi has no client secret. Put the client ID and redirect URI in the app's config (e.g. its environment variables), not hardcoded in several places. When you're done, tell me what you changed and anything I still need to do: in the Chabi developer portal, in the app's environment (and rebuild if those values are baked in at build time), and on the live host. To test, sign in with the app's own button: the portal's try sign-in button only previews the consent screen and can't complete this app's sign-in.Check what it changed before you ship, and try a sign-in end to end. The rest of this page is the reference it follows.
Getting started
- Register your app in the developer portal. You get a client ID; add every redirect URI your app signs in from:
https://…for websites (exact match, plushttp://localhost:…for development), andhttp://127.0.0.1/callbackor your own scheme for desktop apps. Then put the client ID in your app's config and rebuild or redeploy if it's read at build time: registering doesn't change your app. - Add sign-in. The user approves your app once and you get a token for them.
- Call a model with the official OpenAI or Anthropic SDK, using that token as the API key.
- Handle the errors only Chabi sends: out of credits (402), access removed (401) and rate limits (429).
The portal's try sign-in button previews the consent screen with your app's details. It can't finish a sign-in in your app, because your app didn't start it (there's no saved state); test the whole flow from your app's own sign-in button.
Sign in
OAuth 2.0 authorization code flow with PKCE. There's no client secret, so it works in apps that run entirely in the browser.
1. Send the user to sign in
const b64url = (b) =>btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));const state = b64url(crypto.getRandomValues(new Uint8Array(16)));// localStorage, not sessionStorage: email sign-in links can come back in a new tab.localStorage.setItem("chabi:" + state, JSON.stringify({ verifier, at: Date.now() }));const challenge = b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)),);location.assign("https://chabi.dev/oauth/authorize?" + new URLSearchParams({response_type: "code",client_id: "<your client_id>",redirect_uri: "<your redirect URI>",code_challenge: challenge,code_challenge_method: "S256",state,}));
2. At your redirect URI, swap the code for the user's token
const params = new URLSearchParams(location.search);if (params.get("error")) {throw new Error(params.get("error_description") ?? "Sign-in was cancelled.");}const key = "chabi:" + params.get("state");const pending = JSON.parse(localStorage.getItem(key) ?? "null");localStorage.removeItem(key); // each state works onceif (!pending || Date.now() - pending.at > 15 * 60_000) {throw new Error("This sign-in didn't start here, or took too long. Please try again.");}const verifier = pending.verifier;history.replaceState(null, "", location.pathname); // drop ?code from the address barconst res = await fetch("https://chabi.dev/oauth/token", {method: "POST",headers: { "Content-Type": "application/x-www-form-urlencoded" },body: new URLSearchParams({grant_type: "authorization_code",code: params.get("code"),redirect_uri: "<your redirect URI>",client_id: "<your client_id>",code_verifier: verifier,}),});const { access_token: userToken, refresh_token, expires_in } = await res.json();
- The code works once and expires after a minute. A missing
stateentry means the sign-in didn't start in this browser: start again, never skip the check. - The token is for this user in this app only, so it's safe in the browser. It lasts 30 days (
expires_in, in seconds).
3. Keep the user signed in
Before the token expires, or when a request gets 401 invalid_token, POST grant_type=refresh_token, refresh_token and client_id to /oauth/token for a new pair. Each refresh token works once, so always store the new one. Sending one a second time ends the sign-in, including the access token it gave out (it looks like a stolen token). So only one refresh may run at a time across all open tabs: a promise shared within one page isn't enough. The Web Locks API handles it:
// used: the refresh token this tab was holding when it decided to refresh.async function refresh(used) {return navigator.locks.request("chabi-refresh", async () => {// Reread inside the lock: if another tab refreshed while we waited, use its tokens.const saved = JSON.parse(localStorage.getItem("chabi:tokens"));if (saved.refresh_token !== used) return saved;const res = await fetch("https://chabi.dev/oauth/token", {method: "POST",body: new URLSearchParams({grant_type: "refresh_token",refresh_token: saved.refresh_token,client_id: "<your client_id>",}),});if (!res.ok) throw new Error("signed_out"); // invalid_grant: sign in againconst t = await res.json();const tokens = { ...t, expires_at: Date.now() + t.expires_in * 1000 };localStorage.setItem("chabi:tokens", JSON.stringify(tokens));return tokens;});}
- A refresh token lasts 90 days from its last use; after that, or on
invalid_grant, send the user through step 1 again. - Users can remove your app's access from their account at any time. Its token stops working within a minute (401
grant_revoked).
Desktop apps
Desktop apps use the same flow (OAuth for native apps, RFC 8252). Open sign-in in the user's system browser, not a web view inside your app: users can see it's really Chabi, they're often already signed in, and Google sign-in refuses embedded web views. Then get the code back one of two ways:
- loopback
- Register
http://127.0.0.1/callback. Your app listens on any free port and useshttp://127.0.0.1:<port>/callbackas the redirect URI: the port doesn't have to match. Works in every language with no installer changes. - app scheme
- Register your own scheme, like
com.yourcompany.app:/callback(reverse domain name, so it doesn't clash). The browser asks the user to open your app, which receives the URL. Needs the scheme registered with the OS (e.g. Electron'sapp.setAsDefaultProtocolClient).
Loopback in Node or Electron
import http from "node:http";import crypto from "node:crypto";import open from "open"; // opens the system browser (in Electron: shell.openExternal)const verifier = crypto.randomBytes(32).toString("base64url");const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");const state = crypto.randomBytes(16).toString("base64url");// Any free port on this machine.const server = http.createServer();await new Promise((ready) => server.listen(0, "127.0.0.1", ready));const redirectUri = `http://127.0.0.1:${server.address().port}/callback`;await open("https://chabi.dev/oauth/authorize?" + new URLSearchParams({response_type: "code",client_id: "<your client_id>",redirect_uri: redirectUri,code_challenge: challenge,code_challenge_method: "S256",state,}));// The browser comes back here after the user allows (or denies) your app.const params = await new Promise((done) => server.on("request", (req, res) => {const url = new URL(req.url, redirectUri);if (url.pathname !== "/callback") return res.writeHead(404).end();res.end("Signed in. You can close this tab and go back to the app.");server.close();done(url.searchParams);}));if (params.get("state") !== state) throw new Error("Sign-in didn't come from this app.");if (params.get("error")) {throw new Error(params.get("error_description") ?? "Sign-in was cancelled.");}const res = await fetch("https://chabi.dev/oauth/token", {method: "POST",body: new URLSearchParams({grant_type: "authorization_code",code: params.get("code"),redirect_uri: redirectUri, // the same one, port includedclient_id: "<your client_id>",code_verifier: verifier,}),});const { access_token: userToken, refresh_token, expires_in } = await res.json();
- Keep both tokens in the OS keychain (macOS Keychain, Windows Credential Manager; in Electron,
safeStorage), not in a plain file. Refresh as for websites, above. - Then call models exactly as in REQUESTS, from your app's own code. There's no secret to protect: the token only spends this user's credits in your app.
Requests
Chabi speaks two APIs: OpenAI's Chat Completions at https://chabi.dev/v1/chat/completions for GPT models, and Anthropic's Messages at https://chabi.dev/v1/messages for Claude models. Use the official SDKs and change only the base URL and key.
GPT models: the OpenAI SDK
import OpenAI from "openai";const client = new OpenAI({baseURL: "https://chabi.dev/v1",apiKey: userToken,dangerouslyAllowBrowser: true, // the token only spends this user's credits in this app});const stream = await client.chat.completions.create({model: "gpt-6-luna", // you choose; Chabi never swaps itmessages,max_completion_tokens: 4096, // reasoning counts toward this tooreasoning_effort: "none",stream: true,});
Claude models: the Anthropic SDK
import Anthropic from "@anthropic-ai/sdk";const client = new Anthropic({baseURL: "https://chabi.dev",apiKey: userToken,dangerouslyAllowBrowser: true,});const stream = client.messages.stream({model: "claude-sonnet-5-5",max_tokens: 4096, // thinking counts toward this toooutput_config: { effort: "low" },messages,});
Any language: plain HTTP
curl https://chabi.dev/v1/chat/completions \-H "Authorization: Bearer $USER_TOKEN" \-H "Content-Type: application/json" \-d '{"model": "gpt-6-luna", "messages": [{"role": "user", "content": "Hi"}]}'
JSON output
Use each provider's structured outputs; Chabi passes them through. Still check the parsed result in your code.
// OpenAIresponse_format: {type: "json_schema",json_schema: { name: "quiz", schema, strict: true },},// Anthropic: format sits next to effortoutput_config: {effort: "low",format: { type: "json_schema", schema },},
- Everything else in the request goes to the provider as you sent it, so each provider's docs apply. Check MODELS for what each model accepts.
- Every model listed reasons before it answers, and that reasoning counts toward
max_tokens/max_completion_tokens. Leave room for it, or replies come back cut off or empty. - OpenAI:
max_tokensis accepted and sent on asmax_completion_tokens, which these models require. Onlyn: 1is supported. - Anthropic: custom tools work; Anthropic's hosted tools (web search, code execution…) and
speed,fallbacks,service_tier,inference_geo,mcp_servers,containerare refused for now. GET https://chabi.dev/v1/modelslists the models.GET https://chabi.dev/v1/creditsreturns the signed-in user'sbalance_credits, andGET https://chabi.dev/v1/metheir displayname(ornull), so your app can show which Chabi account it's using. Apps never get the user's email.
Models
Every plan includes every model. You choose the model; Chabi never swaps it for another. Credits are charged by the provider's reported usage, per million tokens:
| Model | Endpoint | Credits per 1M tokens, input / output | Request notes |
|---|---|---|---|
gpt-6-astra | /v1/chat/completions | 1,100 / 5,500 | reasoning_effort low to max (not none). Tool calling needs OpenAI's Responses API, which Chabi doesn't serve yet. Requests over 272K input tokens cost 2,200 / 8,250. |
gpt-6.1-sol | /v1/chat/completions | 220 / 1,100 | reasoning_effort low, medium (default), high, xhigh or max; not none. Tool calling needs the Responses API. Requests over 272K input tokens cost 440 / 1,650. |
gpt-6-luna | /v1/chat/completions | 11 / 55 | reasoning_effort none to max (default medium). Function calling only with reasoning_effort none. Requests over 272K input tokens cost 22 / 82.5. |
gpt-5.6-sol | /v1/chat/completions | 550 / 3,300 | reasoning_effort none to max (default medium). Requests over 272K input tokens cost 1,100 / 4,950. |
chat-latest | /v1/chat/completions | 550 / 3,300 | OpenAI's current ChatGPT model. It changes when OpenAI updates it. |
claude-fable-5-1 | /v1/messages | 1,100 / 5,500 | Thinking is always on: leave out thinking, set depth with output_config.effort. Long turns on hard tasks. |
claude-opus-5-5 | /v1/messages | 440 / 2,200 | Thinking is always on; output_config.effort defaults to medium. tool_choice any or tool is refused. |
claude-sonnet-5-5 | /v1/messages | 220 / 1,100 | Thinking is on unless you send thinking: { type: "between_tools" } (effort high or below, no other thinking fields). Effort defaults to high. tool_choice any or tool is refused. |
claude-haiku-5-5 | /v1/messages | 11 / 55 | Thinking is on unless you send thinking: { type: "disabled" } (effort high or below). Effort defaults to medium. Requests over 100K input tokens cost 55 / 275. |
None of these models take temperature or top_p while reasoning. The simplest request works on every Claude model: leave out thinking and set output_config.effort. Only Sonnet and Haiku can turn thinking off, each in its own way (see the notes above); the others refuse thinking: { type: "disabled" }.
Credits
Users pay Chabi a monthly plan with included credits; you pay nothing. Each model's price in credits is in MODELS: pricier models use more.
- Before a request runs, Chabi holds the most it could cost: the input plus the full output cap (4,096 tokens if you don't set one). Afterwards it charges the actual usage and releases the rest. A tight output cap lets users with few credits keep going.
- If the hold doesn't fit the user's balance, the request is refused with 402 before anything is sent to the provider. So a user can get 402 with credits left:
required_creditsis the hold, not the final charge. - The same feature costs a different amount each time, depending on how long the input and reply are. Don't promise a fixed price per action in your UI.
- You earn 30% of the revenue from the paid credits your users spend in your app: credits from their plan or a top-up. Free signup credits don't count. Each month's earnings are ready 30 days after it ends and paid once you're owed at least $25. See Earnings in the developer portal.
Errors
Errors come back in each SDK's own shape, so err.status works as usual. The Anthropic SDK nests the details one level deeper (err.error.error).
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_body model_not_supported unsupported_parameter upstream_rejected | The request is wrong: fix it, don't retry. upstream_rejected carries the provider's own message. |
| 401 | missing_token invalid_token token_revoked grant_revoked unknown_app | Send the user through sign-in again. grant_revoked means they removed your app's access. |
| 402 | insufficient_credits | The user is out of credits. The body has balance_credits, required_credits and upgrade_url: send them there. |
| 403 | app_suspended | Your app was suspended. Email hello@chabi.dev. |
| 413 | body_too_large | Bodies are limited to 1 MB. |
| 429 | user_rate_limited app_rate_limited | Too many requests. Wait for the Retry-After header's seconds. |
| 502, 503 | provider_error provider_overloaded provider_not_configured | The AI provider failed or is busy. Retry with backoff. |
try {// ...the request} catch (err) {const info = err.error?.error ?? err.error; // Anthropic nests one level deeperif (err.status === 402) location.assign(info.upgrade_url);else if (err.status === 401) startSignIn();else if (err.status === 429) retryAfter(Number(err.headers?.get?.("retry-after") ?? 5));}
Limits
- 20 requests a minute per user (across all apps), 300 a minute per app.
- Request bodies up to 1 MB.
- User tokens last 30 days; refresh tokens 90 days from their last use.
Endpoints
- GET /oauth/authorize
- Sign-in and consent screen. Redirects to your redirect URI with
codeandstate, orerror. - POST /oauth/token
- Swaps the code, or a refresh token, for
access_tokenandrefresh_token(form-encoded or JSON). - POST /v1/chat/completions
- OpenAI Chat Completions, for GPT models. Streaming supported.
- POST /v1/messages
- Anthropic Messages, for Claude models. Streaming supported.
- GET /v1/models
- The models Chabi serves.
- GET /v1/credits
- The user's remaining credits.
- GET /v1/me
- The user's display name, or
nullif they haven't set one.
See also
developer portal, ChatApp (a working example app), terms, hello@chabi.dev