Protocol
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=<unix seconds>,v1=<hex>— HMAC-SHA256 of`${t}.${rawBody}`with the endpoint’s secretWind-Event-Id— the event’sevt_…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 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.
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.
{
"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"
}
}
{
"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
{
"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:
import crypto from 'node:crypto'
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.