Integrate
Connect button & 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 <wind-connect> 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.
<script src="https://accounts.usewind.app/button.js" async></script>
<wind-connect
data-client-id="app_1a2b3c"
data-start="/wind/start" <!-- a route on your server -->
data-allocation="1000000000" <!-- optional: suggested spend cap, in micro-cents ($10) -->
data-features="chat,summarize" <!-- optional: feature slugs for the consent screen -->
data-theme="auto" <!-- optional: auto | light | dark -->
data-state="disconnected"> <!-- disconnected | connected -->
</wind-connect>
<wind-connect> element from @usewind/browser /wind/start (a 404 here — there is no server
behind these docs).
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:
<a href="/wind/start" class="wind-connect-button">Connect with Wind</a>
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 <wind-connect> 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
import { wind } from '@usewind/node'
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:
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 covers the same flow in six steps, and Authentication and Making requests document the underlying endpoints.