Amy
Concepts

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

POST /webhooks/terra

Content-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:

PathPurpose
POST /webhooks/terraThe canonical path; documented to integrators.
POST /webhook/terraSingular alias.
POST /webhookTop-level alias for legacy dashboard configs.
POST /terraShort 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:

FieldMeaning
tUnix epoch seconds when Terra signed the payload.
v1HMAC-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:

reasonMeaningRecovery
missing_headerNo terra-signature header present.Confirm Terra is signing webhooks (dashboard setting).
malformed_headerHeader present but doesn't parse as t=…,v1=….Check Terra's webhook format hasn't changed.
bad_timestampt is not a valid integer.Likely upstream bug; report to Terra.
stale|now - t| > 300s.Check your server clock (NTP).
signature_mismatchComputed 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)SourceTriggered by
activitywearableNew workout (run, lift, cycle, etc.).
sleepwearableA completed sleep session.
bodywearableBody composition reading (scale, smart device).
dailywearableEnd-of-day rollup (steps, calories, HRV, RHR).
large_request_processingwearableA backfill chunk landing as part of a historical pull.
lab_reportlab OCRA 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:

  1. Verify HMAC (as above).
  2. Insert into raw_events with dedup_key. Duplicate? Return 200 { duplicate: true }.
  3. Enqueue { rawEventId, request_id } on the terra-events queue.
  4. Return 200 to Terra. Total wall time: typically <50ms.
  5. 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 stamps processed_at on 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_complete

All 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_error onto 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

WhoWhenHow
Terra → AmyAmy 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 5xxWorkflow retries the offending step with exponential backoff, up to 3 attempts per step.
Amy's queue consumerNormaliser throwsPark, 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:

  1. Generate a new secret in Terra's dashboard.
  2. Update both TERRA_WEBHOOK_SECRET (in Cloudflare) and the value in Terra's dashboard. Cloudflare updates are atomic, wrangler secret put TERRA_WEBHOOK_SECRET is the single source of truth.
  3. 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_received

You'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.json

Step 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.json

You'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.json

The 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

On this page