# Wind — full documentation Metered billing gateway for AI. Redirect-only OAuth 2.0 (auth code + PKCE); per-connection access/refresh tokens (rotating), not static keys. Money is integer micro-cents, USD. New apps run in a sandbox (fake balance, mock or live-models provider). API contract: https://docs.usewind.app/openapi.yaml # Overview Source: https://docs.usewind.app/ What Wind is, and the one flow that defines it. Wind is a **metered billing gateway for AI**. A user funds a Wind balance once. Your app adds a *Connect with Wind* button; once the user authorizes it, your app draws from that user's balance on every inference call — priced as the provider's cost times a multiplier. Your app never holds an OpenAI or Anthropic key, never fronts inference spend, and never sees the user's card. ## The shape Wind is the **provider of record**. It holds the provider accounts, meters every call into an immutable ledger, and keeps a service fee. This is delegated *spending authority against a metered resource* — the load-bearing analogies are Plaid (delegated account access) and Stripe Connect (delegated money movement), not "Sign in with Google". ## The flow ``` 1 App → user's browser redirect to accounts.usewind.app/oauth/authorize (PKCE) 2 user logs in, sets a spend cap, approves — once 3 browser → App ?code=… 4 App → api.usewind.app POST /oauth/token (code + verifier + client secret) → access_token + refresh_token + connection_id — steady state, per request — 5 App → api.usewind.app POST /v1/ai/{model} (Bearer access_token) 6 api.usewind.app → App provider completion + a `wind` block: feature, provider_cost_mc, wind_fee_mc, dev_margin_mc, user_charge_mc, allocation_remaining_mc ``` Steps 1–4 run **once per user**. After that your app makes billed calls with the connection's token until the allocation is exhausted or the user revokes. ## What you never handle The user's total balance (you see only per-connection remaining), provider keys, and the user's payment details. ## Next - [Quickstart](/quickstart) — first billed call in a few minutes - [Concepts](/concepts) — the objects and how they fit - [Authentication](/authentication) — the OAuth + PKCE flow in full --- # Quickstart Source: https://docs.usewind.app/quickstart Add Connect with Wind to an application. Choose an integration path. Connect with Wind links a user's Wind balance to your application through a one-time OAuth authorization. Afterward, your application makes metered inference requests that draw against a bounded, per-connection allocation. There are three integration paths. All implement the same protocol and produce an equivalent result. The coding-assistant and SDK paths are recommended for most integrations; the REST API is available for languages without an SDK, edge runtimes, and cases that require full control of the flow. ## Prerequisites Create an application in the [Wind developer console](https://console.usewind.app): 1. Enable developer mode. 2. Open **Apps → New app**. Provide a name, a logo, and one or more exact-match redirect URIs. 3. Record the `client_id` and `client_secret`. The secret is shown once. 4. Under **Settings → Allowed models**, pick the models the app may call. New applications run in a sandbox — a mock provider, and test accounts with fake balances that you create on the console's **Sandbox** page and connect as. See [Sandbox and going live](/sandbox-and-going-live). ## Integration paths ### Coding assistant Provide the [`wind-connect` skill](/skills/wind-connect/SKILL.md) to an AI coding assistant such as Claude Code or Cursor. The skill is a complete description of the integration; the assistant implements it against your codebase. ``` Add "Connect with Wind" to this application so users pay for AI usage from their own Wind balance. Follow https://docs.usewind.app/skills/wind-connect/SKILL.md . The stack is <...>; the application already has user authentication via <...>. ``` See [Agents and LLMs](/agents-and-llms). ### SDK `@usewind/browser` provides the connect button. `@usewind/node` (or `usewind` for Python) provides the server integration: it registers the two OAuth routes and handles token refresh and retry for billed requests. ```js wind.configure({ grantStore }) // credentials from WIND_CLIENT_ID / _SECRET / _REDIRECT_URI app.use(wind.routes({ currentUser: (req) => req.session.userId })) const res = await wind .connection(userId) .model('gpt-4o') .chat({ messages }) ``` See [Connect button and SDK](/connect-button-and-sdk). ### REST API Integrate directly against the HTTP API with no dependency. The [REST API integration guide](/rest-api) covers the flow end to end in six steps. ## Protocol overview Every path performs the same sequence: 1. Redirect the user to `accounts.usewind.app/oauth/authorize` with PKCE parameters. 2. Exchange the returned authorization code for tokens on your server. 3. Persist the `connection_id` and `refresh_token` on your user record. 4. Obtain short-lived access tokens by refreshing. The refresh token is rotated on each use. 5. Send inference requests to `POST /v1/ai/{model}` with the access token and an idempotency key. 6. Handle `allocation_exhausted`, `connection_revoked`, and webhook events. The SDK implements this sequence. [Authentication](/authentication) and [Making requests](/making-requests) document each step in detail. --- # Concepts Source: https://docs.usewind.app/concepts The objects Wind is built from, and how they relate. Money everywhere is **integer micro-cents**, USD. `100_000_000 mc = $1.00` (1¢ = `1_000_000` mc). Money fields end in `_mc` and responses also carry a `_display` string. IDs are prefixed opaque strings. ## Account A **Wind account** (`wac_…`) has credentials (password or Google), optional MFA, and a balance. "Developer" is a capability flag on the same account — it unlocks app registration. There is no separate developer account. ## App An `app_…` is an OAuth client: `client_id`, `client_secret`, name, logo, `redirect_uris[]` (exact-match, HTTPS), and an optional `allowed_models[]` — leave it empty to allow any model Wind offers, or list models to restrict the app to them (the list is also shown on the consent screen). Not a per-connection scope. Also `default_max_tokens`, a `tier` (`sandbox` · `limited-live` · `full-live`), a `provider_mode` (`mock` / `live`), and a `test_allowlist` of who may test it. In sandbox it's used by your **test accounts** — fake users with fake balances. ## Connection A `conn_…` is the grant — unique per `(app, user)`. It holds `reserved_allocation_mc` (non-refilling), `consumed_mc`, `held_mc`, an optional `velocity_limit_mc`, and a `status` (`active` · `exhausted` · `revoked` · `frozen`). Grants are persistent (no expiry) for now. The connection's access token *is* the connection. ## Reserved allocation & holds The user's balance splits into `available` and `reserved`. Connecting moves `min(requested, available)` from available → reserved, ring-fenced to that connection. Each in-flight request places an **authorization hold**: ``` allocation_remaining = allocation_mc − consumed_mc − held_mc ``` Wind estimates a request's max cost from counted input tokens + `max_tokens` at the model's rates (× multiplier, + ~5% margin). If it doesn't fit `allocation_remaining`, the call is rejected **before** any provider spend. On completion the ledger records the actual cost and the remainder is released. Holds auto-expire (~5 min) so a crashed request can't strand funds. ## Velocity limit An optional per-connection cap on spend *rate* (e.g. $/day) within the allocation. Bounds how fast a compromised app can drain the cap. ## Features Dev-defined, lightly-registered, **non-gating** labels: a `slug` (`[a-z0-9-]{1,40}`), `display_name`, `description` (both surface on the consent screen), and optional guardrails (`max_cost_per_call_mc`, `max_tokens`, `rate_limit`, `default_model`). Send one as the `X-Wind-Feature` header, or call `POST /v1/ai/features/{slug}` to use the feature's `default_model`; it lands on `LedgerEntry.feature` and becomes the primary axis in your usage analytics. Unregistered slugs still work — they show as "other". ## Ledger & the four numbers Every request writes one immutable `led_…` row: | Field | Meaning | |---|---| | `provider_cost_mc` | what Wind paid the provider | | `wind_fee_mc` | Wind's service fee | | `dev_margin_mc` | your multiplier margin | | `user_charge_mc` | what the user's balance was debited | Plus `connection_id`, `feature`, `model`, `request_id`, and `status` (`settled` · `reversed`). Every analytics slice and every reversal derives from this row. ## Fees & multiplier pricing `user_charge = provider_cost × your_multiplier + wind_fee`. One multiplier per app for now; per-feature multipliers are a v2 feature policy. ## Sandbox & the go-live ladder An app is in **sandbox** until it's promoted. Sandbox uses a fake per-account balance and a `mock` or `live models` provider. Real money and real end users only enter at `limited-live`. See [Sandbox & going live](/sandbox-and-going-live). --- # Allocations & balance Source: https://docs.usewind.app/allocations-and-balance Reservation lifecycle, holds, non-refilling semantics, increases, revocation. ## Reservation lifecycle - **Connect** — `min(requested_allocation_mc, available)` moves from the user's `available` balance into `reserved`, ring-fenced to the new connection. - **Lower the cap** — the delta returns to `available` immediately. - **Raise the cap** — needs `available` funds, or triggers a top-up. Your app can only *request* an increase; the user approves it. - **Reversal** — a refunded charge credits back to the connection's reserved pool, or to `available` if the connection is revoked. ## Non-refilling An allocation does **not** refill on a schedule. When `consumed_mc` reaches `allocation_mc`, the connection is `exhausted` and every call `402`s until the cap is raised. A refilling cap would mean a compromised app bleeds the balance every period. ## Authorization holds Each in-flight request holds its estimated max cost: ``` allocation_remaining = allocation_mc − consumed_mc − held_mc ``` The estimate is counted input tokens + `max_tokens` at the model's rate, × multiplier, + ~5% margin. If it doesn't fit, the call is rejected before any provider spend. On completion the real cost is written and the rest released. Holds auto-expire after ~5 minutes, so a crashed request can't strand funds, and they make concurrent requests on one connection safe. If `max_tokens` is omitted, the hold cap resolves: `request → feature max_tokens → app default_max_tokens → model ceiling`. ## Requesting an increase ``` POST /v1/connections/{id}/allocation-requests { "target_allocation_mc": 2500000000, "reason": "Heavy user, raising cap" } → 200 { "id": "acr_55", "status": "pending", "current_allocation_mc": 1000000000, "target_allocation_mc": 2500000000, "approval_url": "https://dashboard.usewind.app/allocation-requests/acr_55" } ``` It applies only after the user approves. Learn the outcome via the `allocation_change.approved` / `.denied` webhooks or by polling the connection. A connection has at most one pending request. Filing a new one replaces it: the old request becomes `superseded` and its `approval_url` points the user at the new one, so ask again whenever your number changes. ## Top-ups The user tops up their balance on `dashboard.usewind.app` (Stripe). It never happens through the API, and — in sandbox — not at all: the sandbox balance is fake and effectively unlimited. ## Revocation The user can revoke a connection unilaterally, any time, from the dashboard. Your app should treat `403 connection_revoked` as terminal: mark the user disconnected and prompt a re-connect. ## Paused connections Wind's risk layer watches each connection's pace of spending. Chat calls are compared with their usual rate, and [voice sessions](/voice-sessions) with a normal conversation's spend per minute on that model. Spend that is fast but not alarming is flagged, and your app gets a `connection.risk_warning` webhook (at most every 10 minutes per connection). Spend far past normal pauses the connection (`status: frozen`): - Your app gets a `connection.frozen` webhook, and calls fail with `403 connection_frozen`. A live voice session on it ends with `end_reason: connection_frozen`. - The user gets an email linking to the connection's review page (the `review_url` in the webhook and the error). There they can **resume** the connection, **allow it to spend faster** (up to 3× Wind's usual thresholds), or **revoke** it. - When they resume, your app gets `connection.resumed` and calls work again on the same tokens. The user can also reconnect from scratch. Treat `connection_frozen` as "waiting on the user": stop calling, show `review_url`, and keep the stored grant. Token theft in plain OAuth is a data leak; here it is someone spending the user's money. The cap bounds the total loss and the velocity limit bounds the rate — pair them with short-lived access tokens, mandatory PKCE, and refresh rotation. --- # Sandbox & going live Source: https://docs.usewind.app/sandbox-and-going-live Test accounts and their fake balances, mock vs live models, and the tier ladder. Real money — a real user's balance — only moves once an app **goes live**. Before that an app runs in **sandbox**, where the balance is fake. Every new app starts there. ## The ladder | Tier | Review | Can connect | Money | |---|---|---|---| | **sandbox** | none — instant | your **test accounts** (signed in, or picked by you or a teammate on `test_allowlist`) | fake, non-withdrawable | | **limited live** | lightweight — hours | anyone | **real balances**, with the limits below and a "new app" note on the consent screen | | **full live** | deeper — manual | anyone, higher volume | no tier limits; requires a verified domain + entity info, KYB at higher volume | ### Limited live limits | Limit | Value | When it's hit | |---|---|---| | Spend cap per connection | **$10** by default, **$25** at most | `402 allocation_exhausted`; the user can raise it, up to $25 | | Spend per connection per 24 hours | **$5** | `429 daily_limit_reached` | | Spend across all your connections per 24 hours | **$250** | `429 app_spend_limited` and an `app.spend_limit_reached` webhook | The limits apply only to live connections (sandbox ones spend fake money) and are worked out from your app's current tier on every call, so they lift the moment the app is promoted to full live — connections that ran out only because of the $10/$25 cap become usable again. A connection's own cap and daily limit still apply if they're lower. Full live isn't open yet. Its domain and entity checks are still being built, so for now apps go as far as limited live and keep its limits. Wind can also suspend any app at once, on spend anomalies, revocation spikes or chargebacks: its calls return `403 app_suspended`, its tokens stop refreshing and it can't take new connections until Wind lifts it. Wind's platform risk engine runs on every app too, freezing or throttling a connection whose spend spikes against the app's normal pattern. ## Test accounts Sandbox apps are used by **test accounts**: fake Wind users you create, each with a **fake** balance. They sign in to Wind like anyone else, so you (or a tester you share the login with) can try your app end to end — connecting, spending, running out, approving a cap increase, revoking — without real money. Create them on the console's **Sandbox** page: give each a name and a starting balance, and Wind generates a sign-in email on `test.usewind.app` and a password. The password is shown once; reset it there if it's lost. Up to 25 per developer. - A test account signs in with its password only. Its address never gets email, so sign-in links and password-reset emails don't work for it. - Its Wind dashboard shows its fake balance, and it can set that balance itself (so can you, from the Sandbox page). - It can connect only to **your** sandbox apps — never a live app, another developer's app, real money, or developer mode. - Removing one signs it out, revokes its connections, and keeps its usage history. You can also skip signing in as one: open your sandbox app's consent screen as yourself (or as a teammate on the app's `test_allowlist`) and it asks which of your test accounts to connect as. Either way the connection, its tokens and every charge belong to the test account, never to a real wallet. If you have no test accounts yet, the consent screen makes *Test account 1* with $500. The Sandbox page also totals what your test accounts spent, by account and by app, in fake currency. Allocations, holds, `allocation_exhausted`, and the four ledger numbers all behave exactly as in production, computed from a synthetic price sheet. Connections made before test accounts existed belong to the real account that approved them and keep working. New sandbox connections are always made by a test account. ## Provider mode Set per app, in **App → Integration → Provider mode**. | Mode | Returns | Cost | For | |---|---|---|---| | **mock** (default) | deterministic canned completions (and a canned tool call when you pass `tools`), real token counts + cost block | zero | OAuth, token rotation, ledger shape, holds, exhaustion, webhooks, errors | | **live models** | real OpenAI, Claude and Gemini output, billed to the test account's fake balance | Wind still pays the provider | developing prompts, picking a model | Live-model sandbox calls are the only real cost, so Wind covers **$1 of real provider cost per 24 hours** per developer, shared by all their test accounts across all their apps (allowlisted teammates who connected before test accounts existed each keep their own $1). Mock mode has no such limit. ### Your own provider keys To keep testing on live models past Wind's $1, add your own OpenAI, Anthropic or Google key on the console's **Sandbox** page. Once Wind's allowance for the day is used up, a live-model call runs on your key for that model's provider instead — billing, limits and the ledger still behave as usual against the test account's fake balance, and your provider bills you directly. Calls on your key don't count toward Wind's $1. Your keys are encrypted, never shown again after you save them, and only used for your own sandbox apps. Past Wind's allowance, a call whose provider you haven't added a key for returns `429 sandbox_live_cost_limited`. Add a key, switch the app to mock mode, or wait for the 24-hour window to roll over. ## Free credits for new users Once your app is live, Wind pays for each new user's first usage of it, so people can try your app before adding a card. Every live app gets a monthly pool (currently **$50**, refilling on the 1st, UTC). A user's calls on your app are free until they've used **$2** there (or **$10** across all Wind apps); after that their own balance pays, as usual. - Nothing changes in your integration: allocations, holds, limits, the ledger and the `wind` cost block behave the same. - Each call is paid by one source — the pool or the user's wallet — never both. - Free calls are charged at cost: they earn you **no margin**. - Your console's app **Overview** shows how much of this month's pool is used; the consent screen tells eligible users their first usage is free. - Sandbox apps and test accounts never use the pool. Wind may change these amounts. ## Going live Request promotion from **App → Overview → Go live** in the console. The card shows a checklist first; the required items are: - an API client and at least one redirect URI - at least one registered feature - the allowed models chosen A logo and live models are recommended but not required. Without a logo the consent screen shows your app's initial. Submitting creates a go-live request for the next tier up (`sandbox` → `limited-live`, and later `full-live` once it opens) and puts it in Wind's review queue. Your app keeps its current tier until the request is reviewed, and you can only have one request pending at a time. When it's approved, the app moves to the new tier and its provider mode switches to live; if it's rejected, the app stays where it was and you can apply again. --- # Agents & LLMs Source: https://docs.usewind.app/agents-and-llms Machine-readable docs and a drop-in skill for coding agents. An AI coding assistant can implement the integration from a single instruction file. This page lists the machine-readable resources it should be given. ## Procedure 1. Create an application in [console.usewind.app](https://console.usewind.app) and record the `client_id` and `client_secret`. In sandbox you connect as one of your test accounts (console → **Sandbox**); one is made for you. 2. In your coding assistant (Claude Code, Cursor, and similar), provide the [prompt](#prompt-starter) below. 3. Set `WIND_CLIENT_ID`, `WIND_CLIENT_SECRET`, and `WIND_REDIRECT_URI` in the server environment, then test the flow. The assistant reads the [`wind-connect` skill](#the-wind-connect-skill) and produces the same integration described in the [REST API integration guide](/rest-api). ## Machine-readable docs | File | What it is | |---|---| | [`/llms.txt`](/llms.txt) | Index — one line per page, with links. Follows the [llms.txt](https://llmstxt.org) convention. | | [`/llms-full.txt`](/llms-full.txt) | Every doc page concatenated into one file — paste the whole thing into a model's context. | | [`/openapi.yaml`](/openapi.yaml) | The API contract, source of truth. | ## The wind-connect skill [`/skills/wind-connect/SKILL.md`](/skills/wind-connect/SKILL.md) is a self-contained, copy-pasteable guide that walks an agent through the entire integration: the button, the two backend routes (`/wind/start` + `/wind/callback`), PKCE, the token exchange, making billed calls with refresh-and-retry, the error table, and the gotchas that actually break things (refresh-token rotation, `redirect_uri` exact match, idempotency keys). Drop it into a Claude Code / Cursor project, or just:{' '} curl https://docs.usewind.app/skills/wind-connect/SKILL.md ## Prompt starter > Add "Connect with Wind" to this app so users pay for AI from their own Wind > balance. Follow https://docs.usewind.app/skills/wind-connect/SKILL.md . My > stack is `<...>`; my app already has user auth via `<...>`. --- # Connect button & SDK Source: https://docs.usewind.app/connect-button-and-sdk The hosted connect button web component and the @usewind/browser and @usewind/node packages. A complete integration is one script tag in the page and one route on the server. The script renders the connect button; the server route performs the OAuth token exchange. The SDK packages provide both. There is no client-only flow. Authorization returns a refresh token that can spend from the user's balance, so it must be exchanged and stored on the server. Every integration has a server component. ## Connect button `@usewind/browser` registers a `` custom element. The button itself is rendered in an iframe served by `accounts.usewind.app/button/v1`, so the host page can't restyle or spoof it. Users see one consistent button that authorizes real spending. If the frame doesn't respond within 2 seconds, the element falls back to rendering its own button in a shadow root. ```html data-allocation="1000000000" data-features="chat,summarize" data-theme="auto" data-state="disconnected"> ``` Selecting the button navigates the top-level window to `data-start`. For pages that do not load the script, a plain link to the same route is equivalent: ```html Connect with Wind ``` Because the button authorizes spending, a consistent, recognizable appearance is a security property. Brand assets (SVG and PNG lockups, spacing and minimum-size rules) are provided for teams that render their own markup, and should be used unmodified. ## SDK packages Two packages, separated by where they run: | Package | Runtime | Contents | |---|---|---| | `@usewind/browser` | Browser | the `` custom element | | `@usewind/node` | Node | the two OAuth routes, access-token refresh with retry, the billed-request client, webhook verification | | `usewind` (PyPI) | Python | the same, with a FastAPI router | The split is deliberate: `@usewind/node` handles the `client_secret` and the rotating refresh token, neither of which may appear in browser code. ### Server integration ```js wind.configure({ clientId: process.env.WIND_CLIENT_ID, // these three default to the env vars clientSecret: process.env.WIND_CLIENT_SECRET, redirectUri: process.env.WIND_REDIRECT_URI, webhookSecret: process.env.WIND_WEBHOOK_SECRET, // optional — enables wind.verify() // the one persistence contract. `set` runs on first connect AND every // refresh-token rotation, and is awaited before the new tokens are used. grantStore: { get: userId => db.users.getWindGrant(userId), // → { connectionId, refreshToken, ... } | null set: (userId, grant) => db.users.setWindGrant(userId, grant), delete: userId => db.users.clearWindGrant(userId), }, }) app.use(wind.routes({ startPath: '/wind/start', // GET — builds the PKCE parameters, redirects to accounts callbackPath: '/wind/callback', // GET — verifies state, exchanges the code, writes the grant currentUser: req => req.session.userId, onConnected: userId => log.info({ userId }, 'wind connected'), // optional notification, not persistence successRedirect: '/settings?wind=connected', // the default is '/?wind=connected' deniedRedirect: '/settings?wind=denied', // the user clicked Deny; the default is '/?wind=denied' })) // a billed request — token refresh and the idempotency key are handled internally const res = await wind.connection(userId).model('gpt-4o').chat({ messages, max_tokens: 500 }) // res.wind.{ user_charge_mc, allocation_remaining_mc, ... } // or bill a feature, using its default_model (POST /v1/ai/features/{slug}) const res2 = await wind.connection(userId).feature('chat').chat({ messages }) // webhooks — pass the raw body const event = wind.verify({ payload: rawBody, signature: req.headers['wind-signature'] }) ``` For Next.js or any fetch-based runtime, use `wind.handler({ currentUser })` instead of `wind.routes(...)`. ### When the user denies Deny is a normal answer, not an error. The routes check `state`, then send the user to `deniedRedirect` with no grant written, no token exchange, and nothing passed to `onError` or logged. Show something friendly there: ```js if (new URLSearchParams(location.search).get('wind') === 'denied') { showNotice('No problem. You can connect Wind any time from Settings.') } ``` A denial with a `state` that doesn't match is still treated as a CSRF error, and any other `error` from Wind still goes to `onError`. When you call `completeCallback` yourself, a denial throws `WindAccessDeniedError`. The application supplies one `grantStore` (`get` / `set` / `delete`). `memoryGrantStore()` is exported for local development. The SDK never assumes a datastore, and `set` is the only persistence it depends on — `onConnected` / `onGrantRotated` are optional notifications whose failure can't strand a rotated token. ## Without the SDK The SDK wraps the HTTP API; nothing it does is unavailable directly. The [REST API integration guide](/rest-api) covers the same flow in six steps, and [Authentication](/authentication) and [Making requests](/making-requests) document the underlying endpoints. --- # REST API integration Source: https://docs.usewind.app/rest-api The complete connect flow without an SDK, in six steps. This guide implements Connect with Wind against the HTTP API directly, with no dependency. It is intended for languages without an SDK, edge runtimes, and integrations that require full control of the flow. The [Wind SDK](/connect-button-and-sdk) is a convenience layer over the same protocol. The examples use Node. The steps are identical in any environment that provides a CSPRNG, SHA-256, base64url encoding, HTTP Basic authentication, and an HTTP client. This guide assumes an application with a `client_id` and `client_secret`, still in sandbox, where you connect as one of your test accounts (see [Quickstart](/quickstart)). ``` WIND_CLIENT_ID=app_xxx WIND_CLIENT_SECRET=wsk_xxx # server-only, never in a client bundle WIND_REDIRECT_URI=https://yourapp.com/wind/callback ``` Money is integer **micro-cents**, USD. `100_000_000 mc = $1.00` (1¢ = `1_000_000` mc). --- ## 1 · Redirect the user to Wind On a "Connect with Wind" click, build PKCE + `state`, stash them against the session, and redirect. ```js const b64url = (b) => b.toString('base64url') app.get('/wind/start', (req, res) => { const verifier = b64url(crypto.randomBytes(48)) const challenge = b64url(crypto.createHash('sha256').update(verifier).digest()) const state = b64url(crypto.randomBytes(24)) req.session.wind = { verifier, state } // MUST persist — verified on callback const q = new URLSearchParams({ response_type: 'code', client_id: process.env.WIND_CLIENT_ID, redirect_uri: process.env.WIND_REDIRECT_URI, code_challenge: challenge, code_challenge_method: 'S256', state, requested_allocation_mc: '1000000000', // optional — $10 suggestion the user edits features: 'chat,summarize', // optional — names the consent "what for" line }) res.redirect(`https://accounts.usewind.app/oauth/authorize?${q}`) }) ``` The user logs into Wind, sets a spend cap on the consent screen, and approves. Wind redirects to `WIND_REDIRECT_URI` with `?code=...&state=...`. The `code` is single-use, ~60 s TTL. If the user clicks **Deny**, Wind redirects to the same URI with `?error=access_denied&state=...` and no `code`. Handle it as a normal outcome: check `state`, then send the user back into your app (step 2 shows it). ## 2 · Exchange the code — server-side ```js app.get('/wind/callback', async (req, res) => { const { code, state, error } = req.query const saved = req.session.wind if (!saved || state !== saved.state) return res.status(400).send('bad state') delete req.session.wind // The user clicked Deny — not an error. Nothing to exchange or store. if (error === 'access_denied') return res.redirect('/settings?wind=denied') if (error) return res.status(400).send(`Wind connect failed: ${error}`) const r = await fetch('https://api.usewind.app/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from( `${process.env.WIND_CLIENT_ID}:${process.env.WIND_CLIENT_SECRET}`, ).toString('base64'), }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: process.env.WIND_REDIRECT_URI, // must match step 1 exactly code_verifier: saved.verifier, }), }) if (!r.ok) return res.status(502).send('token exchange failed') const t = await r.json() // { access_token, expires_in, refresh_token, connection_id, allocation_mc } await persistWind(currentUserId(req), t) res.redirect('/settings?wind=connected') }) ``` ## 3 · Store the refresh token securely ```js async function persistWind(userId, t) { await db.users.update(userId, { windConnectionId: t.connection_id, windRefreshToken: t.refresh_token, // rotates on every use windAccessToken: t.access_token, windAccessExpiresAt: Date.now() + t.expires_in * 1000, }) } ``` Every refresh returns a new refresh_token and invalidates the old one. Persist the new one in the same transaction as whatever triggered the refresh. A crash in between locks the user out — they must re-connect. ## 4 · Mint / refresh access tokens ```js async function windAccessToken(user) { if (user.windAccessToken && user.windAccessExpiresAt > Date.now() + 60_000) { return user.windAccessToken } const r = await fetch('https://api.usewind.app/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from( `${process.env.WIND_CLIENT_ID}:${process.env.WIND_CLIENT_SECRET}`, ).toString('base64'), }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: user.windRefreshToken, }), }) if (!r.ok) throw new Error('wind refresh failed — user must reconnect') const t = await r.json() await db.users.update(user.id, { // store the NEW refresh token windRefreshToken: t.refresh_token, windAccessToken: t.access_token, windAccessExpiresAt: Date.now() + t.expires_in * 1000, }) return t.access_token } ``` ## 5 · Send billed requests " /> Model in the path (any model Wind offers, or one in `allowed_models` if the app sets it); OpenAI-shaped body. ```js async function windChat(user, { model = 'gpt-4o', messages, max_tokens }) { const token = await windAccessToken(user) const r = await fetch(`https://api.usewind.app/v1/ai/${model}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': crypto.randomUUID(), 'X-Wind-Feature': 'chat', // optional — analytics + guardrails 'Content-Type': 'application/json', }, body: JSON.stringify({ messages, max_tokens }), }) const data = await r.json() if (!r.ok) throw Object.assign(new Error(data.error?.code || 'wind_error'), { status: r.status, data, }) return data // data.choices[...] + data.wind.{ user_charge_mc, allocation_remaining_mc, ... } } ``` Detail: [Making requests](/making-requests) (streaming, token accounting, the `wind` block). ## 6 · Idempotency & webhooks - **Idempotency** — every `POST /v1/ai/*` needs an `Idempotency-Key`. Reuse the same value to retry (network blip, `502 provider_error`) without double-charging. - **Errors** — handle at least `401 unauthorized` (refresh once and retry), `402 allocation_exhausted` (prompt top-up; the error carries an `increase_url`), `403 connection_revoked` (terminal: clear the tokens, prompt reconnect) and `403 connection_frozen` (paused: show the user the error's `review_url` so they can resume it). Full table: [Errors & types](/errors-and-types). - **Webhooks** — optional but recommended: verify `Wind-Signature`, then react to `connection.exhausted` / `connection.revoked` / `allocation_change.approved`. See [Webhooks](/webhooks). Until you add them, poll `GET /v1/connections`. ## Other languages Same six steps. Python: `secrets.token_urlsafe`, `hashlib.sha256`, `base64.urlsafe_b64encode`, `requests`/`httpx` with HTTP Basic on `/oauth/token`. Edge runtimes: Web Crypto `crypto.subtle.digest('SHA-256', …)` and `crypto.getRandomValues`. --- # Authentication Source: https://docs.usewind.app/authentication The auth-code + PKCE flow, access & refresh tokens, and client credentials. Wind uses **redirect-only OAuth 2.0 authorization code + PKCE**. There is no Link mode, no popup mode, and no `sk_live_` API key. Three distinct things do the work: 1. **Client credentials** (`client_id` + `client_secret`) — identify your app, used with HTTP Basic on `/oauth/token` and the management endpoints. 2. **Access token** — bearer credential for `POST /v1/ai/*`; it *is* the connection. Short-lived (~15 min). 3. **Refresh token** — mints new access tokens. ~90 days, **rotates on every use**, scoped to one connection. ## Authorize | Param | Required | Notes | |---|---|---| | `response_type` | yes | `code` | | `client_id` | yes | `app_…` | | `redirect_uri` | yes | exact match against a registered URI | | `code_challenge` | yes | `base64url(sha256(verifier))` | | `code_challenge_method` | yes | `S256` | | `state` | yes | CSRF token you generate and later verify | | `requested_allocation_mc` | no | pre-fills the spend cap the user edits | | `features` | no | comma list of feature slugs — names the consent "what for" line | The user logs in (email link / password / Continue with Google), sees the consent screen, sets the allocation, and approves. Wind redirects to your `redirect_uri` with `?code=…&state=…`. The `code` is single-use, ~60 s TTL, and useless without the client secret and PKCE verifier. ## Token exchange ``` grant_type=authorization_code code= redirect_uri= code_verifier= → { access_token, expires_in, refresh_token, connection_id, allocation_mc, ... } ``` Persist `{ connection_id, refresh_token }` on the app's user record; cache the `access_token` with its expiry. ## Refresh ``` grant_type=refresh_token refresh_token= → { access_token, expires_in, refresh_token } # new refresh_token — store it ``` Each refresh invalidates the previous refresh token. If you fetch a new one but crash before persisting it, that connection is dead and the user must re-connect. Write it in the same transaction as the work that used it. ## The consent screen Content is closer to a payment authorization than a scope list: the app (name, logo, verification state), the spend cap (pre-filled, editable, shown against the balance), a one-line feature-derived "what for" sentence, an optional velocity limit, a collapsed model/provider disclosure, and how to revoke. New features do not re-prompt. --- # Making requests Source: https://docs.usewind.app/making-requests The inference endpoint — body, response, streaming, feature tagging, idempotency. " /> The model is in the path — any model Wind offers, or one in the app's `allowed_models` if you've restricted it. The body is OpenAI-shaped. ## Calling by feature " /> Same body and response, but the model comes from the feature's `default_model` (set in the console) instead of the path. Changing models then needs no deploy. The feature must exist and have a `default_model`, or the call fails with `400 feature_model_not_configured`. If the app sets `allowed_models`, the resolved model must be in it. ## Models Wind serves OpenAI, Anthropic (Claude) and Google (Gemini) models behind the same request and response shape. The current list, with what Wind pays per token, is public: ```bash curl https://api.usewind.app/v1/models ``` ```json [{ "id": "claude-sonnet-5-5", "provider": "anthropic", "input_cost_per_1k_usd": "0.002000", "output_cost_per_1k_usd": "0.010000" }, …] ``` Use these ids exactly in the path. Users pay the provider cost plus Wind's fee and your margin (see [Concepts](/concepts#ledger--the-four-numbers)). Models are added and retired over time; a call to a retired model fails with `400 invalid_request`, so check the list when choosing models rather than assuming one stays available forever. GPT-5 and o-series models are reasoning models: they only accept the default temperature, so leave it out for them. ## Headers | Header | Required | Notes | |---|---|---| | `Authorization` | yes | `Bearer ` — the token is the connection | | `Idempotency-Key` | yes | a UUID; replays return the original result and never double-charge. Reusing a key with a different body is `409 idempotency_key_reused` | | `X-Wind-Feature` | no | a feature slug — analytics bucket + that feature's guardrails | | `Content-Type` | yes | `application/json` | ## Body ```json { "messages": [{ "role": "user", "content": "Describe this plate." }], "max_tokens": 500, "temperature": 0.7, "stream": false } ``` `max_tokens` is **optional**. When omitted, the authorization hold's cap resolves `request → feature max_tokens → app default_max_tokens → model ceiling`. ## Response The provider completion, plus a `wind` block: ```json { "id": "chatcmpl-…", "choices": [ … ], "usage": { "prompt_tokens": 42, "completion_tokens": 130, "total_tokens": 172 }, "wind": { "feature": "plate-scan", "provider_cost_mc": 184000, "wind_fee_mc": 18400, "dev_margin_mc": 27600, "user_charge_mc": 230000, "allocation_remaining_mc": 476120000 } } ``` ## Tools Pass OpenAI-style function `tools` (plus optional `tool_choice` and `parallel_tool_calls`). This works the same for every provider: Wind translates to Anthropic's and Gemini's formats and back, so responses always carry OpenAI-shaped `tool_calls` and `finish_reason: "tool_calls"`. ```json { "messages": [{ "role": "user", "content": "Weather in Oslo?" }], "tools": [{ "type": "function", "function": { "name": "get_weather", "description": "Current weather for a city", "parameters": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] } } }] } ``` To send results back, append the assistant message **unchanged**, then one `tool` message per call: ```json { "role": "tool", "tool_call_id": "call_…", "content": "{\"temp_c\": 12}" } ``` Keep the assistant message exactly as Wind returned it. On Gemini models a tool call can carry extra_content.google.thought_signature, and Gemini rejects the next turn if it's missing. `parallel_tool_calls: false` is honored on OpenAI and Anthropic models and ignored on Gemini. In sandbox **mock** mode the canned model calls the first (or forced) tool with `{}` arguments, then answers in text once the last message is a tool result. That's enough to exercise your tool loop end to end. ## Images Content parts of type `image_url` take a `data:` URL or a public `http(s)` URL. For Anthropic and Gemini models Wind downloads remote images itself (max 5MB each, 20 per request), so the provider never has to reach your host. A URL that can't be fetched, isn't an image, or points at a private address is rejected with `400 invalid_request` before anything is held or billed. ## Streaming Set `"stream": true`. Wind proxies the provider's SSE stream; the `wind` block arrives as a final event once the response completes and the ledger row is written. ## Metering & idempotency Every call writes one immutable ledger row (see [Concepts](/concepts#ledger--the-four-numbers)). A retried request with the **same** `Idempotency-Key` returns the original response and is not billed again — use this for network retries and on `502 provider_error`. The response never includes the user's total balance — only allocation_remaining_mc for this connection. --- # Voice sessions Source: https://docs.usewind.app/voice-sessions Live speech-to-speech with OpenAI Realtime models, billed per reply from the user's balance. A voice session is a live, spoken conversation between your user and an OpenAI Realtime model (`gpt-realtime-2.1`, `gpt-realtime-mini`). Billing works like chat: each model reply is charged to the user's Wind balance at provider cost plus Wind's fee and your margin. The audio itself never passes through Wind. The user's browser talks to OpenAI directly over WebRTC, so Wind adds no latency. Wind watches the call from the server side and bills each reply. Nothing secret reaches the browser. It gets a single-use ticket that's good for one offer exchange and nothing else. In mock mode, voice sessions run on Wind's mock voice: a canned placeholder reply instead of a real model, so you can build the whole integration for free. To talk to a real model, switch the app's provider mode to Live models in the console. Sandbox apps work with live models too, charged to the test account's fake balance. ## How it fits together 1. **Your server creates the session** with the connection's access token and passes `session.client` to the browser. 2. **The browser connects** with `connectVoice(client)` from `@usewind/browser`. That is the browser's only call to Wind. After it, audio flows straight to OpenAI. 3. **Your server attaches to the control channel** (optional). Use it to answer tool calls, read per-reply receipts, steer the conversation and end the call. ## 1 · Create the session (server) " /> ```ts app.post('/voice/start', async (req, res) => { const session = await wind .connection(req.user.id) .model('gpt-realtime-2.1') .realtime.create({ instructions: 'You are a friendly cooking coach.', voice: 'marin', tools }) res.json(session.client) // { offer_url, ticket, expires_at } — safe for the browser watch(session) // keep the control channel open after responding (step 3) }) ``` As with chat, you can pass `feature` instead of `model` to use the feature's `default_model`. The model has to be in the app's `allowed_models`. `tools` take the chat shape (`{ type, function: { name, description, parameters } }`) or the Realtime flat shape, and Wind converts between them. Creating the session also places the hold for the first reply. If that hold doesn't fit the connection's allocation, the call fails with `402` the same way a chat call does. The ticket expires after about 60 seconds, so create the session when the user presses the call button, not ahead of time. ## 2 · Connect the browser ```ts const client = await (await fetch('/voice/start', { method: 'POST' })).json() const call = await connectVoice(client) // asks for the mic, plays the model's audio hangUpButton.onclick = () => call.hangUp() ``` Without the SDK, create an `RTCPeerConnection`, add the microphone track (or a `recvonly` audio transceiver), and create a data channel named `oai-events` **before** the offer. Then POST the SDP offer: " /> Send `Content-Type: application/sdp` with the offer as the body. The `201` response body is the SDP answer, which you pass to `setRemoteDescription({ type: 'answer', sdp })`. CORS is open to any origin. A ticket that's wrong, expired or already used gets `401 invalid_ticket`. ## 3 · The control channel (server) `session.control()` opens the session's `control_url` WebSocket (`wss://api.usewind.app/v1/realtime/sessions/{id}/control`, authenticated with the same access token) and yields one event per message. ```ts async function watch(session) { for await (const ev of session.control()) { if (ev.type === 'response.function_call_arguments.done') { await session.toolResult(ev.call_id, await runTool(ev)) } if (ev.type === 'wind.response.settled') console.log(ev.wind.user_charge_mc) } } ``` `toolResult` sends the `function_call_output` item followed by a `response.create`, which lets the model continue speaking. Without a control channel, Wind answers every tool call with an error result so the conversation can keep going. To use tools you need the control channel. **Wind → your server** | Event | Carries | |---|---| | `wind.session.started` | `session_id`, `charged_mc`, `response_count`, sent when you connect | | `wind.response.settled` | one per billed reply: `response_id`, `status`, `usage_source` (`provider` · `estimated`), `usage`, `wind: { hold_amount_mc, provider_cost_mc, wind_fee_mc, dev_margin_mc, user_charge_mc }`, `session: { charged_mc, response_count }` | | `wind.session.spend_warning` | `message`: the call is spending faster than a normal conversation | | `wind.session.budget_checkpoint` | `charged_mc`, `block_mc`, `blocks`, `approval_required` (see [Limits](#limits)) | | `wind.session.config_reset` | `fields`: the browser changed locked config and Wind put it back | | `wind.session.ending` | `reason`, `in_seconds?`: e.g. a 60-second warning before `max_duration_s` | | `wind.session.ended` | `session_id`, `connection_id`, `model`, `end_reason`, `response_count`, `charged_mc`, `duration_s`. The socket closes after this. | Wind also relays these OpenAI events: `response.function_call_arguments.done` (`call_id`, `name`, `arguments`), `response.output_audio_transcript.done`, `response.output_text.done`, `input_audio_buffer.speech_started` / `speech_stopped`, `response.created` and `error`. **Your server → Wind** `session.update`, `conversation.item.create`, `response.create`, `response.cancel`, `conversation.item.truncate`, `{ type: 'wind.session.end', when? }` and `{ type: 'wind.session.approve_block' }`. Any other event type gets back an `error` with code `invalid_request`. Your server is trusted, so the config it sets with `session.update` is what Wind enforces. Input transcription isn't available. If the browser changes `instructions`, `voice` or other session config, Wind puts them back, unless you listed them in `client_updatable` when creating the session (`voice` and `instructions` only). Set `on_control_lost: "end"` to end the call 10 seconds after your control channel drops. The default is `"continue"`. Close codes: `4004` means the session wasn't found or isn't yours. `4009` means the session isn't live. ## Ending a session ```ts await session.end({ when: 'immediate' }) ``` " /> - `after_heard` (default): waits until the user has heard the current reply, then hangs up. - `immediate`: cancels the reply in progress, bills the part already generated, then hangs up. A session that is still `pending` ends at once. The browser hanging up (`call.hangUp()`, or closing the tab) also ends it. Every end fires the [`realtime_session.ended`](/webhooks) webhook. Use `GET /v1/realtime/sessions/{id}` for the session's status, `end_reason`, totals and one receipt per billed reply. ## Limits | Limit | Default | What happens | |---|---|---| | `max_duration_s` | 900 (range 30–3540) | `wind.session.ending` 60 s before, then the call ends (`max_duration`) | | `max_cost_mc` | none | no reply starts that could take the session past it, and the call ends (`max_cost`) | | `max_output_tokens` | 1024 (about 51 s of speech) | caps each reply, up to the model's maximum | | idle timeout | 5 min of silence | ends with `idle_timeout` | | sessions per connection | 2 pending or live | a third gets `429 too_many_sessions` | **Budget checkpoints.** Set `budget_block_mc` to get a `wind.session.budget_checkpoint` each time the session's spend crosses another block, for example every $0.50. Add `confirm_each_block: true` to require approval: unless your server sends `wind.session.approve_block` within 30 seconds, the call ends at the next reply with `budget_not_approved`. Use this when your app should ask the user before a long call keeps spending. ## How voice is billed - **Per reply.** Each model reply is billed as its own call, with its own ledger entry: provider cost (per-modality tokens, where audio costs far more than text and reasoning tokens count as text) plus Wind's fee plus your margin. `wind.response.settled` and the session's receipts show the four numbers. - **Holds stay one reply ahead.** Before each reply, Wind holds enough for the model's worst-case reply, so replies never wait on Wind. Whatever the reply doesn't use is released when it settles. The connection's remaining allocation must cover that hold, which is at most about $0.19 on `gpt-realtime-mini` and about $0.60 on `gpt-realtime-2.1`. - **Replies the browser cuts off.** If the user's tab closes or their network drops mid-reply, OpenAI never reports usage for that reply. Wind charges an estimate from what it observed (the transcript, plus the session's earlier replies). The estimate is rounded down, capped at the reply's hold, and labelled `usage_source: "estimated"`. Hidden reasoning isn't charged. Failures on OpenAI's side aren't charged. - **Interruptions still bill unheard audio.** The model generates audio faster than real time, so when the user talks over a reply, audio that was generated but never played is still billed. If your users interrupt a lot, keep `max_output_tokens` modest. - **Risk is judged per minute.** Wind compares spend per minute with a normal conversation's pace on that model. At 1.5× it flags the call (`wind.session.spend_warning`, `connection.risk_warning`). At 3× it pauses the connection (see [Paused connections](/allocations-and-balance#paused-connections)). Long calls at a normal pace are fine. ## Mock mode When the app's provider mode is **Mock**, a voice session runs on Wind's mock voice instead of a real model: - **The create response says so:** `mock: true`, a `notice`, and a `client` with `mode: "mock"` and a `mock_url`. The control channel's first event, `wind.session.started`, carries `mock: true` and the notice too. - **`connectVoice(client)` handles it for you.** There's no WebRTC: it opens the mock over a WebSocket, notices when the user has spoken (from the microphone's level), and speaks the mock's replies with the browser's speech synthesis. `call.mock` is `true`, and it logs the notice to the console. The mock is turn-based, so it doesn't listen while it's speaking. - **Every reply is the same placeholder,** which says it's a mock and how to switch to live models. The mock doesn't call your tools. - **Everything else is real:** holds, a receipt per reply (priced from the sandbox price sheet, like mock chat), budget checkpoints, limits, ending and webhooks. That lets you test your billing and control-channel code before you go live. Switch the app to **Live models** in the console to talk to a real model; nothing in your code changes. ## Errors Creating a session can fail the same ways a chat call can: `402 allocation_exhausted`, `403 connection_frozen` / `connection_revoked` / `model_not_allowed`, and `429 sandbox_live_cost_limited`. It can also fail with `400 invalid_request` (not a realtime model) and `429 too_many_sessions`. The offer exchange can return `401 invalid_ticket` or `502 provider_error`. Full table: [Errors & types](/errors-and-types). --- # Webhooks Source: https://docs.usewind.app/webhooks Signed lifecycle events Wind POSTs to your endpoint. Set an HTTPS endpoint under **App → Webhooks** and store the signing secret. Wind POSTs a JSON event with two headers: - `Wind-Signature: t=,v1=` — HMAC-SHA256 of `` `${t}.${rawBody}` `` with the endpoint's secret - `Wind-Event-Id` — the event's `evt_…` id, the same across retries; use it to de-duplicate Verify the signature before trusting the payload. ## Events | Event | When | Do | |---|---|---| | `connection.created` | a user finished the connect flow | provision the user | | `connection.exhausted` | allocation hit zero | stop calling, nudge the user to raise the cap | | `connection.revoked` | user revoked the grant | mark disconnected, prompt re-connect | | `connection.frozen` | Wind's risk layer paused the connection after abnormal spend | stop calling; the user has been emailed and can resume from `review_url` | | `connection.resumed` | the user resumed a paused connection | calls work again | | `connection.risk_warning` | the connection is spending fast enough to be flagged, not yet paused (at most every 10 min per connection) | check for a runaway loop; consider telling the user | | `allocation_change.requested` | an `acr_` increase was filed | send the user to its `approval_url` | | `allocation_change.approved` | user approved an `acr_` increase | the new cap is live; an exhausted connection is `active` again | | `allocation_change.denied` | user denied an `acr_` increase | the cap is unchanged | | `app.spend_limit_reached` | your app hit its tier's daily spend limit (at most once an hour) | expect `429 app_spend_limited` until spend ages out; consider applying for full live | | `request.settled` | a streamed call that ended with `settlement: "pending"` was settled — charged, or released uncharged | record the final cost against that request | | `realtime_session.ended` | a [voice session](/voice-sessions) ended, for any reason | record the session's total | `allocation_change.approved` and `.denied` carry `{ acr_id, connection_id, new_allocation_mc }`. `app.spend_limit_reached` carries `{ app_id, tier, daily_limit_mc }`. `request.settled` carries `{ request_id, connection_id, model, feature, outcome, reason, ledger_id, input_tokens, output_tokens, provider_cost_mc, wind_fee_mc, dev_margin_mc, user_charge_mc }`. `request_id` is the call's `Idempotency-Key`. `outcome` is `charged` or `not_charged`; when not charged, `reason` is `usage_missing` (the provider never produced a usage record), `connection_revoked` or `connection_frozen`, and the token and money fields are null. It only fires for calls that were pending. A stream that ended with `settlement: "settled"` or `"estimated"` was already final, so it doesn't fire. `connection.frozen` carries `{ connection_id, kind, reason, review_url, frozen_at }`. `kind` is `chat` or `realtime` (which kind of spending tripped it). `reason` is a human-readable sentence. `review_url` is the dashboard page where the user can resume the connection, allow it to spend faster, or revoke it. `connection.risk_warning` carries `{ connection_id, kind, reason, review_url }`, and `connection.resumed` carries `{ connection_id, resumed_at }`. See [Paused connections](/allocations-and-balance#paused-connections). `realtime_session.ended` carries `{ session_id, connection_id, model, end_reason, response_count, charged_mc, duration_s }`. `charged_mc` is the session's total across all its billed replies. For `end_reason` values see [Voice sessions](/voice-sessions#ending-a-session). ```json { "id": "evt_c41e", "type": "connection.frozen", "created_at": "2026-10-06T14:02:51Z", "data": { "connection_id": "conn_1a2b", "kind": "realtime", "reason": "Voice spending ran at 3.2× a normal conversation's pace.", "review_url": "https://dashboard.usewind.app/connections/conn_1a2b?review=paused", "frozen_at": "2026-10-06T14:02:51Z" } } ``` ```json { "id": "evt_d7a0", "type": "realtime_session.ended", "created_at": "2026-10-06T14:20:09Z", "data": { "session_id": "rts_4c1d", "connection_id": "conn_1a2b", "model": "gpt-realtime-2.1", "end_reason": "client_closed", "response_count": 14, "charged_mc": 41250000, "duration_s": 312 } } ``` ## Payload ```json { "id": "evt_9f2a", "type": "connection.exhausted", "created_at": "2026-09-06T18:22:04Z", "data": { "connection_id": "conn_1a2b", "app_id": "app_77" } } ``` ## Verifying The SDKs do this for you: `wind.verify({ payload: rawBody, signature: req.headers['wind-signature'] })` in `@usewind/node`, `wind.verify(payload=..., signature=...)` in `usewind`. By hand: ```js function verify(rawBody, header, secret, toleranceSec = 300) { const m = /^t=(\d+),v1=([0-9a-f]+)$/i.exec(header ?? '') if (!m) return false const [, t, sig] = m if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false // replay guard const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest() const provided = Buffer.from(sig, 'hex') return provided.length === expected.length && crypto.timingSafeEqual(provided, expected) } ``` Use the **raw** request body. Re-serialized JSON won't match. ## Delivery Respond `2xx` within 5 seconds. Anything else, including a timeout, is retried after 1 min, 5 min, 30 min, 2 h, 6 h and 15 h, then marked failed. Recent deliveries and their status are listed per endpoint in the console. --- # Errors & types Source: https://docs.usewind.app/errors-and-types The error envelope, every code, and the shapes you get back. ## Error envelope Every non-2xx response: ```json { "error": { "code": "allocation_exhausted", "message": "This connection has spent its reserved allocation.", "type": "insufficient_funds", "connection_id": "conn_1a2b", "increase_url": "https://dashboard.usewind.app/connections/conn_1a2b", "review_url": null } } ``` `increase_url` is set when raising the allocation would help. `review_url` is set only on `connection_frozen`. It links the user to `dashboard.usewind.app/connections/{id}?review=paused`, where they can resume the connection, allow it to spend faster, or revoke it. Both are null otherwise. ## Codes | Status · code | Meaning | What to do | |---|---|---| | `400 invalid_request` | malformed body, missing `Idempotency-Key`, unknown or retired model, unfetchable image, a voice session on a non-realtime model, or a mock session's ticket used for a WebRTC offer | fix the request; not billed | | `400 feature_model_not_configured` | `POST /v1/ai/features/{slug}` on a feature with no `default_model` | set one in the console, or call `/v1/ai/{model}` | | `401 invalid_ticket` | voice offer exchange with a ticket that's wrong, expired (~60 s) or already used | create a new session; tickets are single use | | `401 unauthorized` | access token missing, invalid or past its ~15 min TTL | refresh, retry once | | `402 allocation_exhausted` | connection spent its cap | prompt top-up / raise limit; deep-link `increase_url`, or `POST /v1/connections/{id}/allocation-requests` | | `402 feature_cost_exceeded` | one call blew the feature's per-call guardrail | surface to the user; the call was not billed | | `403 connection_revoked` | user revoked the grant | mark disconnected, prompt re-connect | | `403 connection_frozen` | Wind's risk layer paused the connection after abnormal spend | stop calling and show the user `review_url`. They can resume it there (you get `connection.resumed`) or reconnect. See [Paused connections](/allocations-and-balance#paused-connections) | | `403 app_suspended` | the app is switched off — by Wind, or by you in the console | stop calling and show it; keep stored grants, they work again once it's lifted | | `403 model_not_allowed` | the app sets `allowed_models` and this model isn't in it | bug — fix the model string or widen the allowlist | | `409 idempotency_key_reused` | same `Idempotency-Key` sent with a different body | use a fresh key for a new request | | `429 rate_limited` | a feature's `rate_limit` reached | back off per `Retry-After` | | `429 risk_throttled` | Wind is throttling an unusual spend burst on this connection | back off per `Retry-After` | | `429 daily_limit_reached` | this connection's 24-hour spend limit reached | tell the user; resumes as older spend ages out of the window | | `429 app_spend_limited` | your app's daily spend limit for its tier reached (limited live) | back off per `Retry-After`; apply for full live if you're outgrowing it | | `429 too_many_sessions` | the connection already has 2 pending or live voice sessions | end one first | | `429 sandbox_live_cost_limited` | Wind's daily allowance for live-model calls in sandbox is used up and you have no key for that model's provider | add your own provider key on the console's Sandbox page, switch the app to mock mode, or wait | | `502 provider_error` | upstream provider failed | retry with the **same** `Idempotency-Key` — not billed | ## The `wind` block Present on every successful `POST /v1/ai/*` response: | Field | Type | Meaning | |---|---|---| | `feature` | string · null | the `X-Wind-Feature` slug, or null | | `provider_cost_mc` | int | what Wind paid the provider | | `wind_fee_mc` | int | Wind's service fee | | `dev_margin_mc` | int | your multiplier margin | | `user_charge_mc` | int | debited from the user's balance | | `allocation_remaining_mc` | int | `allocation − consumed − held` for this connection | ## Conventions - **Money** — integer micro-cents, USD. `_mc` fields; a `_display` string accompanies headline amounts. - **IDs** — prefixed opaque strings: `wac_`, `app_`, `conn_`, `led_`, `acr_`, `evt_`, `rts_` (voice session), `rtt_` (voice ticket). Tokens and `code` are opaque, unprefixed, and secret. - **Time** — RFC 3339 UTC. --- # SDKs Source: https://docs.usewind.app/sdks Official Wind SDKs and their status. Two packages, split along the trust boundary: the server package handles the `client_secret` and the rotating `refresh_token` and must never reach a browser. | SDK | Package | Runtime | Status | |---|---|---|---| | **Browser** | `@usewind/browser` | Browser | 0.1.0 — `` button | | **Server** | `@usewind/node` | Node | 0.1.2 — OAuth routes, refresh-and-retry, billed calls + streaming, webhooks | | **Python** | `usewind` (PyPI) | Python | 0.1.2 — same surface, async-first; FastAPI router (Flask/Django to follow) | | iOS | — | Swift | planned | The old trywind / wind-ai packages and the Wind CLI are discontinued — they targeted the previous product. These are rewrites. ## @usewind/node ``` npm install @usewind/node ``` Mounts the connect flow and wraps billed calls. See [Connect button & SDK](/connect-button-and-sdk) for the full setup. ```js app.use(wind.routes({ /* … */ })) const res = await wind.connection(userId) .model('gpt-4o') .chat({ messages, max_tokens: 500 }) ``` ## usewind (Python) ``` pip install usewind ``` ```python from usewind import wind app.include_router(wind.fastapi_router(current_user=lambda r: r.session.get("user_id"))) res = await wind.connection(user_id).model("gpt-4o").achat(params) ``` Every I/O method has an async (`a`-prefixed) form and a sync wrapper. ## @usewind/browser ``` npm install @usewind/browser ``` Or load the hosted build: ```html ```