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/sourcesand 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.
| Platform | API |
|---|---|
| Web | window.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 native | ASWebAuthenticationSession |
| Android native | Custom Tabs with a redirect intent filter |
| CLI / desktop | Print 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:
| Platform | The redirect URL must… |
|---|---|
| Web | Be https://your-real-domain/... (Terra rejects localhost in production). For local dev, tunnel with cloudflared. |
| Expo / RN | Match your app's scheme in app.json, e.g. amy://oauth/terra/callback. |
| iOS native | Match a URL scheme registered in Info.plist under CFBundleURLSchemes. |
| Android native | Match an <intent-filter> registered in AndroidManifest.xml. |
Popup blockers
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
- A whole user flow that ties this together: Recipe: Build a mobile app, see Step 6.
- The full webhook ingest path (so you can debug "data didn't show up"): Concepts: Webhooks.
- The full provider list and per-vendor quirks: Add a new adapter.
- Endpoint reference: API reference: Sources.
Stream events from a turn
Goal: render a live UI from a turn's SSE event stream, spinners, agent names, validator verdicts, and the answer streaming in token by token. The "watch Amy think" feel from the CLI, in any client.
Upload a lab report
Goal: let a user upload a PDF (or phone photo) of their bloodwork, wait for it to be OCR'd and parsed into structured biomarkers, and surface "out of range" markers in your UI.