Instead of asking the user to sync again, POST /v1/sessions/refresh re-pulls a connected account and only bills when data is actually delivered. Today a refresh either answers from cache (free) or asks the user to sign in again through a one-time link — a new sync at your per-sync price that counts toward your sync cap. maxDataAgeSeconds sets how fresh is fresh enough: a recent-enough cached extraction answers synchronously with status: "cached", still fires a session.completed webhook with cacheHit: true, and is not billed.
Coming soon — silent refresh. Once on-device cookie capture ships, refreshes for users who installed the full Connect app will complete in the background with no user interaction for a flat 1 credit — a fraction of a full sync — and will not count toward your sync cap. Nothing about the request, response, or webhooks below changes when it arrives.
Request
POST /v1/sessions/refresh
Idempotency-Key: 9d1f7c3a-2e6b-4a8d-b5c0-1f4e7a9d2b6c
{ "providerId": "aadvantage", "userId": "user-123", "maxDataAgeSeconds": 21600 }202 — cascade enqueued
{
"sessionId": "ses_r3fR2kQ9xW1p",
"status": "refreshing",
"expectedCompletionAt": "2026-07-20T12:08:41.000Z"
}The default limit is 5 successful refreshes per user + provider per minute — a 429 carries Retry-After; cache hits and un-tapped re-auth prompts do not consume the window. Behind the 202, a four-tier cascade runs: fresh cache → server-side silent replay (coming soon) → silent wake of the user's installed Connect app (coming soon) → user-tap re-auth. When they ship, tiers two and three will deliver a normal session.completed with no user involvement; today a cache miss goes straight to the last tier.
Only the last tier needs the user. It fires a session.auth_required webhook — your playbook:
Webhook delivery — session.auth_required
{
"id": "evt_9Rt4wY2kM7cQ",
"type": "session.auth_required",
"tenant": "ten_2wXcV5bN8mK1",
"occurredAt": "2026-07-20T12:04:02.000Z",
"data": {
"sessionId": "ses_r3fR2kQ9xW1p",
"providerId": "aadvantage",
"userId": "user-123",
"connectUrl": "https://connect.odynn.com/link/horizon-travel/ses_c1xN4pV7mB2d",
"urlsExpireAt": 1784550842000,
"lastSuccessfulSyncAt": 1784462621000,
"occurredAt": 1784549042000
}
}connectUrl non-null — prompt the user (your own push or in-app nudge) to tap it before urlsExpireAt; the URLs live 30 minutes. Completing the tap delivers session.completed, billed as a sync at your per-sync price — the user re-authenticated, so it is a new sync.connectUrl: null — the window closed unbilled; start over with a plain POST /v1/sessions (a new sync, at sync rates).
How much refresh you get depends on the path you used to connect:
| Path | Silent refresh | User-tap refresh |
|---|
connectUrl | Coming soon — background, fully silent | Yes (today: every non-cache refresh) |
| Native SDK | Tenant-implemented | Tenant-implemented |
Refreshing a whole population? POST /v1/sessions/refresh/bulk takes a selector (all_active, an explicit user_ids list, or a named segment) and returns a batchId; read progress with GET /v1/sessions/refresh/bulk/:batchId. There is no batch-level webhook — per-user results arrive on your normal session.* events, and items can be skipped as already_refreshing, rate_limited, or payment_required.