# 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
```