Webhooks
Terra is Amy's single source of wearable and lab data. This page covers the inbound webhook contract: how Terra delivers events, how Amy verifies them, what happens after ingest, and how to replay th…
Terra is Amy's single source of wearable and lab data. This page covers the inbound webhook contract: how Terra delivers events, how Amy verifies them, what happens after ingest, and how to replay them for testing.
There is exactly one inbound webhook in v1: POST /webhooks/terra.
Everything Terra sends, workouts, sleep sessions, daily summaries,
body composition, parsed lab reports, lands on this single endpoint.
Amy does not emit outbound webhooks in v1 (the seam is designed,
not built).
Quick navigation
- The endpoint
- HMAC-SHA256 signing
- Idempotency
- Event types
- What happens after ingest
- Retry behavior
- Secret rotation
- Replaying a webhook for testing
- Common mistakes
The endpoint
POST /webhooks/terraContent-Type: application/json. The body is exactly what Terra sends,
unmodified.
Three mount points are accepted as aliases of the same handler, Terra's dashboard "Host" field is hostname-only, so Terra's POST can land on any of these, and Amy treats them identically:
| Path | Purpose |
|---|---|
POST /webhooks/terra | The canonical path; documented to integrators. |
POST /webhook/terra | Singular alias. |
POST /webhook | Top-level alias for legacy dashboard configs. |
POST /terra | Short alias. |
POST / | Root alias (Terra's default with no path). |
All five route to the same handler. Use /webhooks/terra in
documentation, scripts, and your own monitoring.
Response shapes
Successful ingest (200 OK):
{
"ok": true,
"raw_event_id": 4837,
"type": "sleep",
"request_id": "req_01HX2K3M4N5P6Q7R8S9T0V1W2X"
}Duplicate delivery (200 OK, idempotent):
{
"ok": true,
"duplicate": true,
"type": "sleep",
"request_id": "req_01HX2K3M4N5P6Q7R8S9T0V1W2X"
}Bad signature (401 Unauthorized):
{
"error": "invalid_signature",
"reason": "stale",
"request_id": "req_01HX2K3M4N5P6Q7R8S9T0V1W2X"
}Malformed JSON (400 Bad Request):
{
"error": "invalid_json",
"request_id": "req_01HX2K3M4N5P6Q7R8S9T0V1W2X"
}Note: webhook errors use the slim cloud-side error shape, not the
full error.code / error.message / docs_url shape from
Errors. Webhook responses are read by Terra, not by
human or LLM clients, keeping them small and machine-parseable is
the trade.
HMAC-SHA256 signing
Every Terra delivery carries a signature header:
terra-signature: t=1716635400,v1=4f9c3b...Two fields, comma-separated:
| Field | Meaning |
|---|---|
t | Unix epoch seconds when Terra signed the payload. |
v1 | HMAC-SHA256 of ${t}.${rawBody}, hex-encoded. |
How Amy verifies it
1. Extract t and v1 from terra-signature.
2. Reject if missing or malformed.
3. Reject if |now - t| > 300 seconds (5 minute tolerance).
4. Compute HMAC-SHA256(`${t}.${rawBody}`, TERRA_WEBHOOK_SECRET).
5. Timing-safe compare to v1. Reject on mismatch.The implementation lives in
cloud/src/lib/hmac.ts and uses Web
Crypto (works natively in Cloudflare Workers).
Timestamp tolerance
The 5-minute window protects against replay. A captured payload
re-sent more than 5 minutes after Terra's original t is rejected with
reason: "stale". This means:
- Your server clock must be within ~5 minutes of Terra's. NTP is fine; manual clock drift is not.
- Don't queue webhook deliveries client-side and replay them later, they'll fail signature verification.
Failure modes
The reason field on 401 invalid_signature tells you which check
failed:
reason | Meaning | Recovery |
|---|---|---|
missing_header | No terra-signature header present. | Confirm Terra is signing webhooks (dashboard setting). |
malformed_header | Header present but doesn't parse as t=…,v1=…. | Check Terra's webhook format hasn't changed. |
bad_timestamp | t is not a valid integer. | Likely upstream bug; report to Terra. |
stale | |now - t| > 300s. | Check your server clock (NTP). |
signature_mismatch | Computed HMAC doesn't match v1. | Wrong secret. See Secret rotation. |
Idempotency
Terra retries on any non-2xx response, and occasionally double-delivers on its own. Amy is idempotent by content hash:
dedup_key = SHA-256(rawBody)The webhook handler inserts the raw event into raw_events with this
key as a UNIQUE constraint. A duplicate delivery hits the constraint
and the handler returns 200 { duplicate: true } without re-processing.
You can re-deliver the same payload any number of times without double-ingesting it. This is what makes the replay-for-testing flow safe.
Note: idempotency is on the payload bytes, not on Terra's logical event ID. Two semantically-identical events with different serialisations (e.g., reordered JSON keys) will be ingested twice. Terra's serialisation is stable in practice; this hasn't bitten us.
Event types
Terra has two payload shapes: wearable events (with type and user
fields) and lab-report events (with upload_id and data only).
Amy detects the shape structurally.
type (Amy) | Source | Triggered by |
|---|---|---|
activity | wearable | New workout (run, lift, cycle, etc.). |
sleep | wearable | A completed sleep session. |
body | wearable | Body composition reading (scale, smart device). |
daily | wearable | End-of-day rollup (steps, calories, HRV, RHR). |
large_request_processing | wearable | A backfill chunk landing as part of a historical pull. |
lab_report | lab OCR | A PDF you uploaded has finished parsing. |
Lab-report payloads look like this, note the absence of type and
user:
{
"upload_id": "tlr_abc123",
"data": [
{
"metadata": { "test_date": "2026-04-20" },
"biomarkers": [
{ "name": "ldl_cholesterol", "value": 124, "unit": "mg/dL",
"reference_range": "<100" }
]
}
]
}Amy's handler tags these as lab_report and stashes Terra's
upload_id in the reference_id column so the normaliser can join it
back to the original POST /v1/labs upload.
For each event type's full payload shape, see Terra's
docs. Amy's normaliser
is permissive, fields it doesn't recognise are kept in payload for
later schema additions.
What happens after ingest
The webhook handler is ack-fast: it verifies, persists the raw payload, enqueues a normalisation job, and returns within ~50ms. All heavy work happens asynchronously.
The five steps:
- Verify HMAC (as above).
- Insert into
raw_eventswithdedup_key. Duplicate? Return200 { duplicate: true }. - Enqueue
{ rawEventId, request_id }on theterra-eventsqueue. - Return
200to Terra. Total wall time: typically<50ms. - Async consumer picks up the message, looks up the raw row,
runs the normaliser for that event type, inserts typed rows into
summary/activities/sleep_sessions/ etc., and stampsprocessed_aton the raw event.
The raw_events table is your audit trail, every payload Terra ever
sent, with its verification status, dedup key, and any normalisation
error. Useful for debugging "why isn't this workout in my data?"
The request_id propagates from the webhook handler through the queue
into the consumer, so one Terra event's full journey is queryable as a
single trace:
webhook_received → raw_event_inserted → queue_published → normalize_start → normalize_completeAll five log lines share the same request_id.
Failure handling
If normalisation fails (bad schema, type mismatch, runtime error):
- The consumer does not re-deliver, it parks the message by
acking it and writes
process_erroronto the raw row. - A nightly cron job retries parked events.
- The 5-minute "drain" cron picks up raw events that landed but never got queued (rare; safety net).
This means a single bad payload can't poison the queue. You can
inspect parked events via GET /admin/raw_events?errored=true
(admin-only).
Retry behavior
| Who | When | How |
|---|---|---|
| Terra → Amy | Amy returns non-2xx, or no response within Terra's timeout (~30s) | Terra retries with exponential backoff up to ~24h. Same payload, same headers, fresh terra-signature (new t). |
| Amy → Anthropic / OpenRouter (during a turn that consumes the new data) | Upstream returns 5xx | Workflow retries the offending step with exponential backoff, up to 3 attempts per step. |
| Amy's queue consumer | Normaliser throws | Park, don't retry. Nightly cron re-tries parked events. |
Idempotency makes Terra's retry safe, if Amy already ingested the
payload (duplicate: true), the retry is a no-op.
Secret rotation
The HMAC secret lives in Cloudflare as TERRA_WEBHOOK_SECRET. To
rotate:
- Generate a new secret in Terra's dashboard.
- Update both
TERRA_WEBHOOK_SECRET(in Cloudflare) and the value in Terra's dashboard. Cloudflare updates are atomic,wrangler secret put TERRA_WEBHOOK_SECRETis the single source of truth. - There is no overlap window in v1. Webhooks signed with the old
secret will fail with
reason: "signature_mismatch"during the ~30-second window between the two updates. Terra retries up to 24h, so these recover on their own.
If you need an overlap window (zero-loss rotation), cloud/src/lib/hmac.ts
can be modified to accept both secrets during a rollout. Not built
in v1, secret rotation is rare enough that the 30-second blip is
acceptable.
Replaying a webhook for testing
Idempotency makes it safe to replay any captured payload. Pick one
from logs (or capture one with wrangler tail), then re-sign it.
Step 1: capture a payload
wrangler tail amy-cloud | grep webhook_receivedYou'll see lines like:
{"event": "webhook_received", "body_bytes": 4824, "has_signature": true, "request_id": "req_01HX..."}For the actual JSON body, query the admin endpoint with the request ID:
curl -H "x-admin-key: $AMY_ADMIN_KEY" \
"https://api.amy.health/admin/raw_events?request_id=req_01HX..."Save the body locally:
curl -H "x-admin-key: $AMY_ADMIN_KEY" \
"https://api.amy.health/admin/raw_events/4837/payload" \
> payload.jsonStep 2: re-sign with the current secret
SECRET="your_terra_webhook_secret"
TS=$(date +%s)
BODY=$(cat payload.json)
SIG=$(printf "%s.%s" "$TS" "$BODY" | \
openssl dgst -sha256 -hmac "$SECRET" -hex | \
sed 's/^.*= //')Step 3: replay
curl -X POST https://api.amy.health/webhooks/terra \
-H "Content-Type: application/json" \
-H "terra-signature: t=$TS,v1=$SIG" \
--data-binary @payload.jsonYou'll get one of two responses:
200 { duplicate: true }, Amy already ingested this exact payload. Idempotency working as designed.200 { duplicate: false, raw_event_id: <new> }, fresh ingest.
To force a fresh ingest (for end-to-end testing), modify a single byte
in the payload (e.g., bump a timestamp by 1ms). The new dedup_key
will be distinct and the normaliser will re-process it.
Local testing
Against wrangler dev:
curl -X POST http://localhost:8787/webhooks/terra \
-H "Content-Type: application/json" \
-H "terra-signature: t=$TS,v1=$SIG" \
--data-binary @payload.jsonThe local secret comes from cloud/.dev.vars (TERRA_WEBHOOK_SECRET=…).
You can set it to anything, Terra never delivers to localhost; you're
always replaying.
Common mistakes
Clock skew → signature rejected with reason: "stale"
If your server clock drifts more than 5 minutes from Terra's, every
webhook fails. Run ntpd / chronyd. On Cloudflare Workers this is
non-issue (CF sync'd), but local wrangler dev inherits your laptop's
clock, sleep/wake cycles can desync it.
Wrong secret → reason: "signature_mismatch"
The most common cause: dev and prod secrets differ, and you set the
wrong one in Terra's dashboard. Run wrangler secret list to confirm
which env has which secret. The actual secret value isn't readable,
only the names.
Replaying with a stale t
If you re-sign with a fresh t, the signature is valid. If you just
replay the original t and v1, you get reason: "stale" once the
5-minute window passes. Always re-sign with t=$(date +%s).
Treating 200 { duplicate: true } as an error
It's not. It means idempotency caught a re-delivery and saved work. Your monitoring should treat duplicates as informational, not as failures.
Modifying raw_events.payload to "fix" a normalisation error
The raw row is the audit trail. Editing it disconnects what Terra actually sent from what was processed. If the normaliser is wrong, fix the normaliser and re-run it against the original payload, don't rewrite history.
Listening for normalisation completion via webhook
There's no outbound webhook in v1. To know when a Terra event finishes
processing, poll raw_events.processed_at (admin endpoint) or query
the typed table (summary, sleep_sessions, etc.) for the new row.
Assuming sequential ordering
Terra delivers in roughly chronological order but with no guarantee. A late workout webhook can arrive after a daily-summary webhook that already references it. The normaliser handles this with upserts and a reconciliation cron, don't build client logic that assumes strict ordering.
Forgetting Content-Type: application/json on replay
The handler will still try to read the body and verify the signature, but content-type-aware proxies (corporate WAFs, mitmproxy) may strip or rewrap the body, breaking the byte-exact HMAC check. Always send the header explicitly.
Using curl without --data-binary
-d reformats whitespace (it normalises newlines, strips
trailing whitespace). HMAC is over exact bytes, any reformatting
breaks the signature. Always use --data-binary @file.
Not handling lab_report separately
Lab payloads have no type or user field. If your downstream
processor assumes every event has event_type === "activity" | "sleep" | ..., it'll silently drop lab events. Check for the structural
shape (upload_id + data array), Amy's handler already does this.
Where to next
- API reference: Webhooks, endpoint signature.
- Errors:
webhook_signature_invalid, how clients surface signature failures (rarely surfaced, Terra is the only caller). - Internals: Data pipeline, the normaliser, the queue consumer, and the raw_events schema.
- Internals: Runtime, Cloudflare Queues config and cron triggers.
- Recipe: Connect a wearable, the client-side OAuth flow that causes Terra to start sending webhooks.
Memory
What Amy remembers between turns, your goals, your preferences, the insights that earned validation. Memory is read into every turn's context, written to at the end of every turn, and fully visible…
Errors
Every error Amy returns follows the same shape, has a stable code, and links here. This page is the catalog: every code, its HTTP status, when it fires, and how to recover.