Amy
Recipes

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.heyamy.xyz/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/sources and watch the new connection's last_sync_at field flip from null to a timestamp — that happens the moment the first real webhook (daily / sleep / etc., not auth_success) arrives and gets processed. As a fallback, ask Amy a question; the first real turn after connecting will trigger a fresh data pull and return whatever Terra has delivered so far.


STEP 4 — Test mode and sandbox

Terra ships a "test mode" you can switch on for any provider, which hands back deterministic fixture data without needing a real WHOOP / Oura / Garmin account to authorize. Use it to keep the Amy connect flow exercised in CI and during local dev — burning a real WHOOP authorization for every test run quickly hits Terra's rate limits and exhausts the developer's patience.

There is one Terra dev account behind the production TERRA_DEV_ID for now; sandbox mode is toggled per widget session. To use it from the client, pick the test provider in the widget UI ("Test Provider" / whoop_test) instead of the live one. The webhook payload that lands in Amy is identical in shape to a real one — same dedup_key, same received_at, same downstream agent visibility — only the values are synthetic.

For local backend dev:

  1. Sign up at tryterra.co and create a dev project. Copy TERRA_DEV_ID, TERRA_API_KEY, and a webhook signing secret you generate; put them in .dev.vars.
  2. Run cloudflared tunnel --url http://localhost:8787 (or any reverse tunnel) to give the Worker a public URL Terra can post to.
  3. In Terra's dashboard, set the webhook URL to https://<tunnel>/webhooks/terra.
  4. Call amy.sources.terra.connect({ redirect_url: "amy-mobile://terra-callback" }) from your mobile dev client; pick "Test Provider" in the widget.
  5. Watch wrangler tail — within ~10 seconds you'll see webhook.terra lines for auth_success and then synthetic daily / sleep payloads.

There is no separate sandbox endpoint in Terra v1; test mode piggybacks on the same widget + webhook plumbing your prod traffic uses. That means a dev who breaks the webhook handler discovers it the same way prod would.


STEP 5 — Errors the connect flow can throw at you

The happy path covers ~95% of users. The remaining cases each have a specific webhook event you can react to:

Webhook typeMeaningWhat the client should do
auth_successOAuth completed; future events for this user will arriveRefresh GET /v1/sources — the new row should now be present.
user_reauthToken expired or Terra rotated; the user needs to re-authorizeShow a "reconnect WHOOP" prompt; call amy.sources.terra.connect() again with the same redirect_url.
access_revokedUser revoked Amy's access from their provider's settingsMark the source inactive in your UI; offer a "reconnect" CTA. The terra_connections row stays — the next auth_success will reuse it.
auth_failureUser declined inside the widget, or Terra hit an upstream errorShow a friendly retry. Nothing was persisted on Amy's side.
large_request_processingBackfill is in progress; more daily / sleep events still incomingShow "syncing history…"; rely on last_sync_at flipping again when the backfill chunk lands.

Two redirect URLs are available when calling amy.sources.terra.connect(): redirect_url (success) and auth_failure_redirect_url (declined / error). Wire both in mobile to the same deep-link route and read ?status= from the URL to know which path you came back through.

const session = await amy.sources.terra.connect({
  redirect_url:              "amy-mobile://terra-callback?status=success",
  auth_failure_redirect_url: "amy-mobile://terra-callback?status=failure",
});

The Amy backend does not currently surface a typed webhook_signature_invalid event to the SDK — failed verifications return 401 to Terra, are logged with reason: signature_mismatch, and Terra retries with exponential backoff. From the client's perspective, the only symptom of a misconfigured webhook secret is "no data ever arrives." Check wrangler tail or GET /admin/dlq (admin-key auth) to confirm.


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. The connection is marked inactive (the row gets a deactivated_at stamp and stops appearing in /v1/sources), and Terra's access is revoked on the Terra side via the connection lifecycle. Ingested historical data is retained so the user's prior turns can still reference it; nothing is hard-deleted here.

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

If the user wants every trace of a provider gone (e.g. an account deletion request), use the admin tooling — DELETE /admin/user/:userId/wipe hard-deletes the user's rows across users, terra_connections, raw_events, turns, and memory. That endpoint requires AMY_ADMIN_KEY and is intentionally out of reach of the SDK.


Where to next

On this page