Amy
Recipes

Recipe, Connect a wearable

Goal: let a user link their Whoop / Oura / Garmin / Fitbit / Apple Watch (or any of the 30+ providers Terra supports) to Amy. End state: a new row in GET /v1/sources and data flowing in via the Terra…

Goal: let a user link their Whoop / Oura / Garmin / Fitbit / Apple Watch (or any of the 30+ providers Terra supports) to Amy. End state: a new row in GET /v1/sources and data flowing in via the Terra webhook.

The flow is a standard OAuth dance, brokered by Terra so Amy doesn't have to integrate each vendor individually. The whole thing is three API calls and one round trip through a browser:

1. Your client calls   POST /v1/sources/terra/connect → { widget_url }
2. Open widget_url in a browser. Terra runs the OAuth.
3. Terra redirects to your redirect_url. Browser closes.
4. (Async) Terra POSTs an auth_success webhook to Amy.
5. Your client polls/refreshes GET /v1/sources — the new source is there.

STEP 1, Get the widget URL

Call POST /v1/sources/terra/connect with the redirect_url you want Terra to send the user back to when they're done. The redirect URL is how your client knows the OAuth flow finished, not how data arrives. Data arrives via the Terra webhook to Amy's backend (see Architecture: data pipeline and Concepts: Webhooks).

curl -X POST "$AMY_BASE_URL/v1/sources/terra/connect" \
  -H "Authorization: Bearer $AMY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "redirect_url": "amy://oauth/terra/callback" }'

Response:

{ "widget_url": "https://widget.tryterra.co/session/abc123…" }

The widget URL is single-use and expires in ~10 minutes. Open it immediately; don't store it.


STEP 2, Open the widget in the user's browser

How you do this depends on the platform. The pattern is the same: open the URL, wait for the redirect, refresh the source list.

PlatformAPI
Webwindow.location.href = widget_url (full page), or window.open(widget_url) (popup)
React Native (Expo)expo-web-browser's openAuthSessionAsync(widget_url, redirect_url)
iOS nativeASWebAuthenticationSession
Android nativeCustom Tabs with a redirect intent filter
CLI / desktopPrint the URL, ask the user to open it, poll for the source to appear

Web

Full-page redirect is simplest. The browser comes back to your redirect_url when Terra is done.

import { Amy } from "@amy/sdk";

const amy = new Amy({ apiKey, baseUrl });

async function connectWearable() {
  const { widget_url } = await amy.sources.terra.connect({
    redirect_url: `${window.location.origin}/oauth/terra/callback`,
  });
  window.location.href = widget_url;
}

On the callback page, refresh sources:

// app/oauth/terra/callback.tsx
useEffect(() => {
  amy.sources.list().then((list) => {
    setSources(list.data);
    router.replace("/sources");
  });
}, []);

Popup variant, keeps the user on the current page:

async function connectWearable() {
  const { widget_url } = await amy.sources.terra.connect({
    redirect_url: `${window.location.origin}/oauth/terra/done.html`,
  });
  const popup = window.open(widget_url, "_blank", "width=480,height=720");

  // Watch for the popup to close, then refresh.
  const t = setInterval(async () => {
    if (popup?.closed) {
      clearInterval(t);
      const list = await amy.sources.list();
      setSources(list.data);
    }
  }, 500);
}

React Native (Expo)

openAuthSessionAsync is the right primitive: it opens the system's secure browser (SFAuthenticationSession on iOS, Custom Tabs on Android) and resolves when Terra redirects to your scheme.

import * as WebBrowser from "expo-web-browser";
import { Amy } from "@amy/sdk";

const amy = new Amy({ apiKey, baseUrl });

async function connectWearable() {
  const redirectUrl = "amy://oauth/terra/callback"; // declared in app.json

  const { widget_url } = await amy.sources.terra.connect({
    redirect_url: redirectUrl,
  });

  const result = await WebBrowser.openAuthSessionAsync(widget_url, redirectUrl);

  if (result.type === "success") {
    // Browser closed via the redirect → refresh sources.
    const list = await amy.sources.list();
    setSources(list.data);
  } else {
    // User dismissed the browser. Don't error — just no-op.
  }
}

You need a custom URL scheme registered in app.json:

{ "expo": { "scheme": "amy" } }

iOS native (Swift)

import AuthenticationServices

func connectWearable() async throws {
    let url = try await api.connectTerra(redirectURL: "amy://oauth/terra/callback")

    let session = ASWebAuthenticationSession(
        url: url,
        callbackURLScheme: "amy"
    ) { callbackURL, error in
        // Refresh source list on success.
        Task { try? await self.refreshSources() }
    }
    session.presentationContextProvider = self
    session.prefersEphemeralWebBrowserSession = false
    session.start()
}

CLI / desktop

No browser handoff, print the URL and tell the user to open it. Then poll until the source shows up.

const { widget_url } = await amy.sources.terra.connect({
  redirect_url: "https://amy.health/connected", // any harmless landing page
});

console.log("Open this URL in your browser to connect:");
console.log(widget_url);
console.log("\nWaiting…");

const before = (await amy.sources.list()).data.length;
while (true) {
  await new Promise((r) => setTimeout(r, 3_000));
  const list = await amy.sources.list();
  if (list.data.length > before) {
    console.log(`Connected ${list.data.at(-1)!.provider}.`);
    break;
  }
}

STEP 3, The OAuth callback doesn't carry data

This is the most-missed point in the whole flow.

When Terra redirects back to your redirect_url, the callback itself carries no health data. It only signals "the user finished OAuth." The actual data arrives later, asynchronously, when Terra posts an auth_success event (and then daily, sleep, activity, …) to Amy's webhook endpoint at POST /webhooks/terra. The Worker queues those events, normalizes them, and writes to D1.

That's why every snippet above ends with refresh the source list: the source row appears once the auth_success webhook has been processed (usually within a few seconds of the redirect). Recent historical data follows over the next ~30 seconds; a full backfill can take longer depending on how much history Terra is replaying.

If you want to show "connecting…" until data is actually present, poll GET /v1/data/summaries/:date for today's date and wait for it to return a non-empty row. Or, more simply, ask Amy a question, the first real turn after connecting will usually trigger an updated summary.


Common mistakes

Wrong redirect_url

The most common failure mode. Terra has to be able to redirect back to your origin/scheme. Symptoms:

  • Web: Terra shows a "redirect URI mismatch" error mid-flow.
  • Mobile: the browser opens, OAuth completes, then the browser just sits there because the OS doesn't know which app owns the redirect scheme.

Fixes:

PlatformThe redirect URL must…
WebBe https://your-real-domain/... (Terra rejects localhost in production). For local dev, tunnel with cloudflared.
Expo / RNMatch your app's scheme in app.json, e.g. amy://oauth/terra/callback.
iOS nativeMatch a URL scheme registered in Info.plist under CFBundleURLSchemes.
Android nativeMatch an <intent-filter> registered in AndroidManifest.xml.

If you call window.open(widget_url) outside a user gesture (e.g. inside a setTimeout or after an await), the browser will block it. The fix: call window.open synchronously in the click handler, then update the popup's URL once you have it:

async function connectWearable() {
  const popup = window.open("about:blank", "_blank", "width=480,height=720");
  const { widget_url } = await amy.sources.terra.connect({
    redirect_url: `${window.location.origin}/oauth/terra/done.html`,
  });
  if (popup) popup.location.href = widget_url; // safe — popup already exists
}

Expecting data immediately after the redirect

The callback is the start of the data flow, not the end. If you navigate the user to "your sleep dashboard" the moment Terra redirects back, the dashboard will be empty for a few seconds. Either:

  • Show a "syncing your data…" state and refresh on a 3s interval, or
  • Land the user on a generic "connected!" screen and let them navigate manually.

Calling connect repeatedly

Each call mints a new widget session and increments your Terra usage quota. If your UI accidentally retries on every render, you can burn through thousands of widget URLs. Treat the widget URL as a one-shot resource: get it once, open it, await the redirect, and don't call connect again unless the user explicitly retries.

Forgetting to handle disconnect

The reverse flow: when the user wants to unlink a wearable, call DELETE /v1/sources/:id. This revokes Terra's access and deletes ingested data for that source. There's no soft-delete, be sure before you call it.

await amy.sources.disconnect("src_01HX…");

Where to next

On this page