---
name: wind-connect
description: >
  Wire up "Connect with Wind" in a web app — the OAuth (auth code + PKCE) flow
  that links a user's Wind balance to your app, plus making billed AI calls
  through Wind's gateway. Use when someone wants their app's users to pay for AI
  usage from their own Wind balance instead of the developer paying a provider.
---

# Connect with Wind

Wind is a metered billing gateway for AI. A user funds a **Wind balance** once;
your app gets scoped **spending authority** against it and draws from it per
inference call, priced as provider cost × a multiplier. Your app never holds an
OpenAI/Anthropic key and never sees the user's card.

The per-user credential is a **connection** (an access token + a rotating refresh
token), obtained via redirect-only **OAuth 2.0 authorization code + PKCE**. There
is no static API key.

Full docs: <https://docs.usewind.app> · API contract: <https://docs.usewind.app/openapi.yaml>

---

## Before you start

The developer must, once, in <https://console.usewind.app>:

1. Enable developer mode (instant, terms-gated).
2. **Apps → New app** → set a name, an optional logo, and one or more exact-match
   `redirect_uris` (e.g. `https://yourapp.com/wind/callback`, and
   `http://localhost:3000/wind/callback` for local dev). Get `client_id` +
   `client_secret` (**secret shown once**).
3. Pick **Allowed models** under **App → Settings** (current list:
   `GET https://api.usewind.app/v1/models` — OpenAI, Claude and Gemini ids).
4. New apps are in **sandbox**, used by **test accounts**: fake Wind users with
   fake balances, created on the console's **Sandbox** page (one is made for
   you). Sign in to Wind as one with its generated email + password, or pick one
   on the consent screen. Teammates who should be able to test go under
   **App → Integration → Who can test**. The provider is `mock` by default —
   flip to `live models` for real completions.

Store in the server environment (never client-side):

```
WIND_CLIENT_ID=app_xxx
WIND_CLIENT_SECRET=wsk_xxx
WIND_REDIRECT_URI=https://yourapp.com/wind/callback
```

## Money & units

Integer **micro-cents**, USD. `1_000_000 mc = $1.00`. Fields end in `_mc`.

---

## Prerequisite: your app already has its own user auth

A Wind connection **attaches to** an existing signed-in user of your app. Wind is
not your app's login. Every step below assumes you can identify "the current
user" server-side.

---

## 1 · Frontend — the button

Hosted web component (renders in an iframe from Wind, can't be spoofed):

```html
<script src="https://accounts.usewind.app/button.js" async></script>
<wind-connect
  data-client-id="app_xxx"
  data-start="/wind/start"
  data-allocation="1000000000"            <!-- optional: $1000 suggestion the user edits -->
  data-features="chat,summarize"          <!-- optional: names the consent "what for" line -->
  data-state="disconnected">              <!-- disconnected | connected -->
</wind-connect>
```

Or just a link to your own start route:

```html
<a href="/wind/start">Connect with Wind</a>
```

---

## 2 · Backend — `GET /wind/start`

Build PKCE + `state`, stash them against the session, redirect to Wind.

```js
import crypto from 'node:crypto'

const b64url = (buf) => buf.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))

  // MUST persist against this user's session — you verify them on callback
  req.session.wind = { verifier, state }

  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
    features: 'chat,summarize',            // optional
  })
  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 back to `WIND_REDIRECT_URI` with `?code=...&state=...`.

---

## 3 · Backend — `GET /wind/callback`

Verify `state`, exchange the code for tokens (HTTP Basic with your client
credentials), persist `{ connection_id, refresh_token }` on the user. If the
user clicked Deny, the callback has `error=access_denied` and no `code` —
redirect back into the app, don't show an error.

```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 — a normal outcome, not an error.
  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 /wind/start exactly
      code_verifier: saved.verifier,
    }),
  })
  if (!r.ok) return res.status(502).send('token exchange failed')
  const { access_token, expires_in, refresh_token, connection_id } = await r.json()

  await db.users.update(currentUserId(req), {
    windConnectionId: connection_id,
    windRefreshToken: refresh_token,          // rotates on every use — see gotchas
    windAccessToken: access_token,
    windAccessExpiresAt: Date.now() + expires_in * 1000,
  })
  res.redirect('/settings?wind=connected')
})
```

---

## 4 · Making billed AI calls

Get a fresh access token, then call the gateway. The model is in the path —
any model Wind offers, or one in `allowed_models` if the app restricts it. Body is OpenAI-shaped for every
provider — including `tools` / `tool_choice` (responses come back as OpenAI
`tool_calls`) and `image_url` parts (data: or public https URLs). For a tool
round-trip, append the assistant message **unchanged**, then one
`{ role: 'tool', tool_call_id, content }` per call.

```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()
  // ATOMIC: store the NEW refresh token together with whatever used it
  await db.users.update(user.id, {
    windRefreshToken: t.refresh_token,
    windAccessToken: t.access_token,
    windAccessExpiresAt: Date.now() + t.expires_in * 1000,
  })
  return t.access_token
}

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(), // required; reuse it to retry safely
      '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, ... }
}
```

## 5 · Handle these responses

| Status · code | Do |
|---|---|
| `400 invalid_request` | fix the request (missing `Idempotency-Key`, bad body, unknown model); not billed |
| `401 unauthorized` | access token invalid or expired — refresh, retry once (the helper above refreshes early, so this is rare) |
| `402 allocation_exhausted` | tell the user to top up or raise the limit; `data.error.increase_url` deep-links the dashboard |
| `402 feature_cost_exceeded` | one call blew a per-feature guardrail; not billed |
| `403 connection_revoked` / `connection_frozen` | terminal — clear the stored tokens, prompt reconnect |
| `403 app_suspended` | the app is switched off; keep stored grants, they work again once it's lifted |
| `403 model_not_allowed` | bug — the model isn't in the app's allowlist |
| `409 idempotency_key_reused` | same key, different body — use a fresh key for a new request |
| `429 rate_limited` · `risk_throttled` · `daily_limit_reached` · `app_spend_limited` · `sandbox_live_cost_limited` | back off per `Retry-After` / tell the user |
| `502 provider_error` | retry with the **same** `Idempotency-Key` — not billed |

---

## Gotchas — get these wrong and it breaks

- **Refresh tokens rotate.** Every refresh returns a new `refresh_token` and
  invalidates the old one. Persist the new one in the *same* transaction as the
  work that triggered the refresh. A crash in between locks the user out.
- **`redirect_uri` must match exactly** between `/wind/start`, `/wind/callback`,
  and a URI registered on the app. No trailing-slash mismatch.
- **Client secret is server-only.** Never ship it to the browser; never put it in
  a client bundle or a public repo.
- **`Idempotency-Key` is required** on every `POST /v1/ai/*`. Generate one per
  logical request; reuse the same value to retry without double-charging.
- **Sandbox vs live.** In sandbox the balance is fake and, by default, the
  provider is a mock (canned completions). Flip provider mode to "live models" in
  the console to get real output while still billing the test account's fake balance.
- **You never see the user's total balance** — only `allocation_remaining_mc` for
  this connection.

---

## Other stacks

- **Next.js** — put `/wind/start` and `/wind/callback` as route handlers under
  `app/wind/`; use a signed cookie or your session store for `{ verifier, state }`.
- **Python (FastAPI/Flask)** — same shape: `secrets.token_urlsafe`,
  `hashlib.sha256`, `base64.urlsafe_b64encode`, HTTP Basic on `/oauth/token`.
- **SDK** — `@usewind/node` mounts steps 2–3 and wraps step 4. Give it one
  `grantStore` (`{ get, set, delete }` keyed by your user id — `set` handles both
  first connect and every refresh-token rotation, atomically), then
  `app.use(wind.routes({ currentUser }))` and
  `await wind.connection(userId).model('gpt-4o').chat({ messages })` (or
  `.feature('chat').chat({ messages })` to use a feature's default model). The browser
  button ships as `@usewind/browser`. Python: `pip install usewind` (same surface,
  FastAPI router). All three are published (`@usewind/node` and `usewind` 0.1.2, `@usewind/browser` 0.1.0).
