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.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:
- 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. - Run
cloudflared tunnel --url http://localhost:8787(or any reverse tunnel) to give the Worker a public URL Terra can post to. - In Terra's dashboard, set the webhook URL to
https://<tunnel>/webhooks/terra. - Call
amy.sources.terra.connect({ redirect_url: "amy-mobile://terra-callback" })from your mobile dev client; pick "Test Provider" in the widget. - Watch
wrangler tail— within ~10 seconds you'll seewebhook.terralines forauth_successand then syntheticdaily/sleeppayloads.
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 type | Meaning | What the client should do |
|---|---|---|
auth_success | OAuth completed; future events for this user will arrive | Refresh GET /v1/sources — the new row should now be present. |
user_reauth | Token expired or Terra rotated; the user needs to re-authorize | Show a "reconnect WHOOP" prompt; call amy.sources.terra.connect() again with the same redirect_url. |
access_revoked | User revoked Amy's access from their provider's settings | Mark the source inactive in your UI; offer a "reconnect" CTA. The terra_connections row stays — the next auth_success will reuse it. |
auth_failure | User declined inside the widget, or Terra hit an upstream error | Show a friendly retry. Nothing was persisted on Amy's side. |
large_request_processing | Backfill is in progress; more daily / sleep events still incoming | Show "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:
| 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. 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
- 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 — phases, agent timings, validator verdicts, 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.