White-label (connectUrl)

The white-label approach: hand your user Odynn's hosted, brandable Connect surface — an App Clip / Instant App, no install — and read normalized loyalty data back over the REST API. Create a session, hand off the link, learn the outcome, read the data — plus webhooks, refresh, branding, and costs.

Coming soon. The backend path is live and this walkthrough is complete, but the surface your user lands on is an App Clip — distributed from the Odynn Connect app's App Store listing — so it opens when that listing does. Read this to plan ahead; to integrate today, use the Native SDK — iOS path, which embeds the same sync inside your own app.

Step-by-step: the connectUrl flow

Six steps from an empty account to loyalty data in your product. The examples use a fictional tenant, Horizon Travel, connecting an AAdvantage account — swap in your own ids. Full endpoint schemas live in the API reference; the guide shows one compact example per step.

1Get an API key

Every call to the REST API is authenticated with a tenant API key, created in the Dev Console.

Open API Keys and click New API key. There is one kind of key: every key is prefixed odc_live_ and runs under your plan's limits, and completed syncs and refreshes debit your tenant's real credit balance. The Save your API key screen shows the plaintext exactly once; copy it before closing.

Send the key on every request as Authorization: Bearer odc_live_… or X-Api-Key: odc_live_…. Keys are server-side only — never ship one to a browser, mobile app, or client bundle. Your users never need a key: the links you hand them carry their own short-lived credentials.

An authenticated request
curl https://connect.odynn.com/api/v1/providers \
  -H "Authorization: Bearer odc_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
200 — the provider catalog
{
  "providers": [
    {
      "id": "aadvantage",
      "name": "American Airlines AAdvantage",
      "programType": "airline",
      "supportedDataTypes": ["profile", "balance", "tier", "activity"],
      "status": "active",
      "loginUrl": "https://www.aa.com/loyalty/login"
    }
  ]
}

GET /v1/providers lists the loyalty programs enabled for your tenant (active or beta); use its id values as providerId in the next step.

2Create a session

A session is one connect attempt for one user and one provider. Create it from your backend, not the client.

POST /v1/sessions needs only providerId, but always set metadata.userId — your own correlation id for the end user (pattern ^[A-Za-z0-9._\-:@]{1,128}$). It is what makes the user-scoped reads and the refresh API usable later. metadata.referenceId (same pattern) is a free second correlation slot. An optional https-only returnUrl (max 2048 chars) gives the completion screen a way back into your app.

The response carries the connectUrl this guide uses — plus two deadlines: workerArrivalTimeoutAt (a device must connect by then, default 2 minutes, else the session expires) and authTimeoutAt (the user must finish the provider login by then, default 5 minutes, else it fails). The id doubles as the Native SDK's session handle if you ever switch paths.

Request
POST /v1/sessions
Idempotency-Key: 5b0c1a2e-4f7d-4b9a-9c3e-8d2f6a1b0c4d

{
  "providerId": "aadvantage",
  "metadata": { "userId": "user-123", "referenceId": "onboarding-2026-07" },
  "returnUrl": "https://app.horizontravel.example/loyalty/linked"
}
201 — the launch URL, two deadlines
{
  "id": "ses_8fQ2mL0xW3vT",
  "status": "pending",
  "providerId": "aadvantage",
  "connectUrl": "https://connect.odynn.com/link/horizon-travel/ses_8fQ2mL0xW3vT",
  "workerArrivalTimeoutAt": "2026-07-20T12:02:00.000Z",
  "authTimeoutAt": "2026-07-20T12:05:00.000Z",
  "returnUrl": "https://app.horizontravel.example/loyalty/linked",
  "metadata": { "userId": "user-123", "referenceId": "onboarding-2026-07" },
  "createdAt": "2026-07-20T12:00:00.000Z"
}

Send an Idempotency-Key header on the create so a network retry can never mint a second session — replays return the original response with an Idempotent-Replayed: true header.

metadata.userId is a tenant-private value. Never put secrets or personal data in it — an opaque id from your own user table is ideal.

3Hand connectUrl to your user

The connectUrl is a Universal Link that opens the Odynn-branded Connect experience — an iOS App Clip / Android Instant App, no install required.

On mobile, render it as a button or deep link in your own app. On desktop, render the same URL as a QR code — the phone's OS camera decodes it (iOS 11+ / Android 8+); the Connect apps deliberately have no in-app scanner.

What the user experiences: the App Clip card appears, the Connect surface opens, and they sign in to the loyalty program inside a real WebView on their own phone — credentials never reach Odynn or your servers. A branded sync loader covers the extraction, then a success screen offers the hand-back to your returnUrl and an optional prompt to install the full app (which is what will unlock silent refresh when it ships). See the storyboard below.

The link your user taps (or scans)
https://connect.odynn.com/link/horizon-travel/ses_8fQ2mL0xW3vT

Sessions are single-use and short-lived: mint a fresh session per connect attempt and never reuse or cache one. The first device to connect — via either path — claims the session; a second device is rejected.

4Learn the outcome

Prefer webhooks: subscribe an endpoint to session.completed, session.failed, and session.expired and react when the event arrives.

Every delivery is a signed JSON envelope — id, type, tenant, occurredAt, data. Your metadata.userId rides along in data.metadata, so you can route the event to the right user without a lookup. (Envelope occurredAt is an ISO string; timestamps inside data are epoch milliseconds.)

No webhooks yet? Poll GET /v1/sessions/:id. The status walks the lifecycle in order: pending → connected → authenticating → extracting, ending at completed, failed, cancelled or expired (two more statuses, refreshing and auth_required_final, appear only on refresh sessions).

Webhook delivery — session.completed
{
  "id": "evt_7Kd2xQ9pR4nA",
  "type": "session.completed",
  "tenant": "ten_2wXcV5bN8mK1",
  "occurredAt": "2026-07-20T12:03:41.000Z",
  "data": {
    "sessionId": "ses_8fQ2mL0xW3vT",
    "providerId": "aadvantage",
    "completedAt": 1784549021000,
    "metadata": { "userId": "user-123" }
  }
}
Fallback: GET /v1/sessions/ses_8fQ2mL0xW3vT (excerpt)
{
  "id": "ses_8fQ2mL0xW3vT",
  "status": "extracting",
  "providerId": "aadvantage",
  "connectedAt": "2026-07-20T12:01:12.000Z",
  "completedAt": null,
  "failureReason": null,
  "failureMessage": null
}

Verify the Odynn-Signature header on every delivery — the full recipe is in Webhooks below.

5Read the data

Once a session completes, the normalized extraction is yours to read — per session or per user.

GET /v1/sessions/:id/data returns the result of one session. For product surfaces you will usually prefer the user-scoped reads keyed by your own metadata.userId: GET /v1/users/:userId/sync (the latest completed sync per connected provider) and GET /v1/users/:userId/historical-syncs (every sync, newest first, cursor-paginated via nextCursor).

The canonical payload: profile (always present, with memberNumber), balances[] with kind of redeemable, qualifying or lifetime and an open lowercase currency label (miles, points, loyalty_points, nights, …), tier (name, optional validUntil), reservations[] (flight / hotel_stay), awards[], promotions[], activities[], and a required fetchedAt. Objects are passthrough — providers may add extra fields, so tolerate keys you don't know.

GET /v1/sessions/:id/data — 200
{
  "sessionId": "ses_8fQ2mL0xW3vT",
  "extractedAt": "2026-07-20T12:03:39.000Z",
  "data": {
    "profile": { "memberNumber": "7A2BC34", "name": "Alex Rivera", "status": "Platinum" },
    "balances": [
      { "kind": "redeemable", "currency": "miles", "amount": 184220, "label": "AAdvantage miles" },
      { "kind": "qualifying", "currency": "loyalty_points", "amount": 61230 },
      { "kind": "lifetime", "currency": "miles", "amount": 912400 }
    ],
    "tier": { "name": "Platinum", "validUntil": "2027-03-31" },
    "reservations": [
      { "kind": "flight", "startDate": "2026-08-04", "confirmationNumber": "QXRTLM",
        "summary": "DFW → LHR · AA50 · Aug 4, 2026" }
    ],
    "awards": [
      { "kind": "upgrade_certificate", "label": "Systemwide Upgrade", "count": 2,
        "expiresAt": "2027-01-31" }
    ],
    "fetchedAt": "2026-07-20T12:03:37.000Z"
  }
}
GET /v1/users/user-123/sync — latest per provider (data trimmed)
{
  "userId": "user-123",
  "providers": [
    {
      "providerId": "aadvantage",
      "sessionId": "ses_8fQ2mL0xW3vT",
      "extractedAt": "2026-07-20T12:03:39.000Z",
      "data": { "profile": { "memberNumber": "7A2BC34" }, "fetchedAt": "2026-07-20T12:03:37.000Z" }
    }
  ]
}

An unknown or not-yet-synced userId returns an empty providers array — not a 404 — so the read is safe to issue optimistically.

/data returns 404 extraction_result_not_found until the session has a result.

6Build your UI

A handful of payload fields carries a whole loyalty dashboard. The annotated mock below maps them onto Horizon Travel's screen.

balances[0].amount (the redeemable entry) feeds the headline balance, tier.name the status chip, reservations[0].summary the upcoming-trip row, and profile.memberNumber the account footer. When a program surfaces a qualifying counter it arrives as a kind: "qualifying" entry with amount: 0 rather than being omitted — but programs without a qualifying ladder omit the entry entirely (and balances itself is optional), so guard progress meters on the entry's presence.

The fields behind the mock
{
  "balances": [ { "kind": "redeemable", "currency": "miles", "amount": 184220 } ],
  "tier": { "name": "Platinum" },
  "reservations": [ { "kind": "flight", "summary": "DFW → LHR · AA50 · Aug 4, 2026" } ],
  "profile": { "memberNumber": "7A2BC34" }
}
Steps 5–6. Horizon reads the normalized payload and maps four fields onto its own loyalty screen — the callouts name the exact fields.

Show fetchedAt as a “last updated” hint and pair it with the refresh API (Keeping data fresh) to keep the screen current.

What your user sees

The whole journey through Horizon Travel's eyes — from the CTA in their app, through the Odynn Connect surface, and back. The App Clip card and store listing are always Odynn / TravelerDNA; the surface in between takes your branding once Enable branding is on.

Step 3. Horizon Travel's own app offers the connect flow — the button (or a QR on desktop) opens the connectUrl from POST /v1/sessions.
Steps 3–4. The App Clip opens the Odynn Connect surface. The user signs in to the provider in a WebView on their own phone — credentials never reach Odynn — then the branded three-ring loader covers the sync.
Step 4. On completed the surface hands back to your returnUrl — and your session.completed webhook fires. Installing the full app is what will unlock silent refresh (coming soon).
Steps 5–6. Horizon reads the normalized payload and maps four fields onto its own loyalty screen — the callouts name the exact fields.

Best practices

Webhooks

Create an endpoint on Webhooks with Create endpoint — use a public https URL; deliveries to private or loopback addresses are rejected at delivery time (dead-lettered). The Save your signing secret screen reveals the signing secret exactly once. These are the events an endpoint can subscribe to:

  • session.completed
  • session.failed
  • session.expired
  • session.auth_required
  • extraction.completed
  • extraction.failed
  • credit.low
  • credit.exhausted
  • auto_recharge_failed

Every delivery carries exactly three headers: Odynn-Signature: t=<unix>,v1=<hex>[,v1prev=<hex>], Odynn-Event-Id, and Content-Type: application/json. Verify the HMAC-SHA256 signature over `${t}.${rawBody}` with a constant-time compare before trusting anything:

Signature verification
// Odynn-Signature: t=1784549021,v1=5f8a…   (+ ",v1prev=…" during rotation grace)
const [t, v1, v1prev] = parseOdynnSignature(req.headers["odynn-signature"]);

const expected = createHmac("sha256", signingSecret)
  .update(`${t}.${rawBody}`)   // rawBody = the exact request body bytes
  .digest("hex");               // lowercase hex

const valid =
  timingSafeEqual(expected, v1) ||
  (v1prev !== undefined && timingSafeEqual(expected, v1prev));

// Ack with any 2xx within 15 s, then process asynchronously.
// Dedupe on the Odynn-Event-Id header — it is stable across retries.

A delivery succeeds only on a 2xx within the 15-second per-attempt timeout — return 200 immediately and process the event async. Failures retry on a backoff of 30 s → 2 m → 10 m → 1 h across 5 total attempts, then the delivery is dead-lettered. The exact cadence is platform-configured and can vary by environment — design for retries arriving hours later, not for a fixed schedule. Retries resend a byte-identical body and signature — the t timestamp does not advance — so do not enforce a tight replay tolerance, and dedupe on Odynn-Event-Id instead.

When you rotate a secret (Rotate signing secret on the endpoint's Manage page), deliveries are signed with both secrets for the grace window (default 24 hours): v1 is the new secret, v1prev the old one — accept either. Use Send test delivery to push a synthetic webhook.test event through the real signed pipeline before going live.

Keeping data fresh

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:

PathSilent refreshUser-tap refresh
connectUrlComing soon — background, fully silentYes (today: every non-cache refresh)
Native SDKTenant-implementedTenant-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.

Branding

By default the Connect surface your users see carries the Odynn / TravelerDNA identity. Flip Enable branding on the Branding page (admin) to theme it with your display name, colors, logo and splash assets, font, and support link — then Save & Publish. It is entirely self-serve and applies on the app's next launch; no need to contact Odynn.

Branding themes the connectUrl Connect-app surface only. It never touches your own Native SDK embed, and the App Store name stays TravelerDNA. Partial branding degrades gracefully — anything you leave unset falls back to the built-in default, so you can publish a display name and a primary color and stop there.

Credits & costs

Pricing is denominated in credits: 1 credit = 1¢ USD. A full sync (a completed POST /v1/sessions flow) costs your plan's per-sync rate, a refresh that needs the user to sign in again costs the per-sync rate, a cache hit is free, and plain API calls are free. Silent refresh (coming soon) will cost a flat 1 credit. Balances and rates live on Billing (admin), where Buy credits tops up ($1 buys 100 credits) and Auto-recharge keeps the balance above a threshold automatically.

Two 402 responses gate billable work before it starts: payment_required (balance cannot cover the action — details carries priceCredits and totalCredits; top up or wait for auto-recharge) and tier_cap_reached (the plan's monthly sync cap is exhausted — details carries used and cap; a refresh that ends in the user signing in again counts as a sync). Treat both as retryable-after-action, not as errors to hammer.

Don't wait for the 402: subscribe a webhook endpoint to credit.low (fires when the balance crosses the warning threshold, 7,500 credits by default), credit.exhausted (the balance can no longer cover one sync), and auto_recharge_failed (the saved card declined or the monthly cap was hit).

Integration hygiene

Start on Pay-as-you-go. Evaluate on the Pay-as-you-go tier: real syncs against real providers at 15 credits per sync, with no monthly commitment. When your volume justifies it, move to Core or Pro from the plan picker on Billing — the upgrade goes through a Stripe Checkout and starts as soon as payment completes (changes between committed plans take effect at the next renewal).

Idempotency. Send an Idempotency-Key header (any unique string, e.g. a UUID) on mutating calls — session create, refresh, key create, webhook mutations. A replay within 24 hours returns the original response with Idempotent-Replayed: true.

Rate limits. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After in seconds. Back off until then instead of retrying hot.

Errors. Every error is { "error": { "code", "message", "details", "requestId" } } — branch on code, and quote requestId when you contact support. Resources you don't own return 404, never 403, so a 404 can mean “not yours” as well as “not found”.

Sessions are disposable. Mint a fresh session per connect attempt; never reuse, share, or persist the URLs — they are single-use and expire in minutes.

The dashboard, page by page

Sessions

Watch connect attempts live. Filter by status, provider, dates, or your own user id; View a session for its timeline, metadata, one-click URL copies, normalized data, and a Cancel session button while it is still running. The Refresh link shows refresh-cascade history and keepalive health per user.

API Keysadmin

Mint credentials with New API key, copy the one-time reveal, and Revoke anything you rotate away from. The table shows each key's preview, name, and last use.

Webhooksadmin

Create endpoint, pick the events, save the one-time signing secret. Manage opens delivery history, Rotate signing secret (with a grace window), and Send test delivery.

Brandingadmin

The Enable branding toggle plus your display name, colors, font, support links, and asset uploads — with a live phone preview. Save & Publish versions the manifest.

Billingadmin

Credit balance, plan and renewal, invoices, and the levers: Buy credits, the plan picker, and Auto-recharge with a monthly safety cap.

Usage

API-call volume and credit consumption over any range — by action, by provider, and as an itemized debit ledger. Export CSV for your own analysis.

Settings

Your tenant profile and account-level preferences.

Teamadmin

Invite teammates and manage roles. Members get the read-only surfaces; admins additionally manage keys, webhooks, branding, billing, and the team itself.

Audit Log

Every mutation on your account — who did what, when, including actions taken by Odynn staff on your behalf.

Going further

API reference — the full, always-current schema for every endpoint, header, and error this guide summarized. When the guide and your response disagree, the reference wins.

Native SDK embeds — integrating the sync surface inside your own app is covered by the Native SDK — iOS guide (Android coming soon). The OdynnConnectSDK package resolves from its public repository — the reference and the changelog are in that guide's Requirements section, and App Review notes covers submitting your own app.

Deep dives — the refresh API and its billing semantics (docs/integration-guide/refresh-api.md), the transport-by-transport refresh matrix (docs/integration-guide/refresh-availability-matrix.md), and the re-auth notification playbook (docs/integration-guide/expired-session-notifications.md), plus copy-paste snippets in docs/_examples/.