terra-webhooks
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTerra Webhooks
Terra Webhooks
Handle real-time health data delivery from Terra API.
处理来自Terra API的实时健康数据推送。
Quick Start
快速开始
python
from flask import Flask, request
import hmac
import hashlib
app = Flask(__name__)
TERRA_SIGNING_SECRET = "your_signing_secret_from_dashboard"
@app.route("/webhooks/terra", methods=["POST"])
def handle_terra_webhook():
# 1. Verify signature
signature = request.headers.get("terra-signature")
if not verify_signature(signature, request.get_data()):
return "Invalid signature", 401
# 2. Parse payload
payload = request.get_json()
event_type = payload.get("type")
# 3. Handle event
if event_type == "activity":
handle_activity(payload)
elif event_type == "sleep":
handle_sleep(payload)
elif event_type == "auth":
handle_user_connected(payload)
# 4. Respond immediately
return "OK", 200
def verify_signature(header: str, body: bytes) -> bool:
"""Verify Terra webhook signature."""
parts = dict(p.split("=") for p in header.split(","))
timestamp = parts["t"]
signature = parts["v1"]
message = f"{timestamp}.{body.decode()}"
expected = hmac.new(
TERRA_SIGNING_SECRET.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)python
from flask import Flask, request
import hmac
import hashlib
app = Flask(__name__)
TERRA_SIGNING_SECRET = "your_signing_secret_from_dashboard"
@app.route("/webhooks/terra", methods=["POST"])
def handle_terra_webhook():
# 1. Verify signature
signature = request.headers.get("terra-signature")
if not verify_signature(signature, request.get_data()):
return "Invalid signature", 401
# 2. Parse payload
payload = request.get_json()
event_type = payload.get("type")
# 3. Handle event
if event_type == "activity":
handle_activity(payload)
elif event_type == "sleep":
handle_sleep(payload)
elif event_type == "auth":
handle_user_connected(payload)
# 4. Respond immediately
return "OK", 200
def verify_signature(header: str, body: bytes) -> bool:
"""Verify Terra webhook signature."""
parts = dict(p.split("=") for p in header.split(","))
timestamp = parts["t"]
signature = parts["v1"]
message = f"{timestamp}.{body.decode()}"
expected = hmac.new(
TERRA_SIGNING_SECRET.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)Webhook Event Types
Webhook事件类型
Authentication Events
认证事件
| Event | Description |
|---|---|
| User successfully connected |
| User disconnected |
| User re-authenticated |
| Provider revoked access |
| Connection failed |
| 事件 | 描述 |
|---|---|
| 用户成功连接 |
| 用户断开连接 |
| 用户重新认证 |
| 服务商撤销访问权限 |
| 连接失败 |
Data Events
数据事件
| Event | Description |
|---|---|
| New workout/activity data |
| New sleep session data |
| Body metrics update |
| Daily summary update |
| Nutrition/meal data |
| Cycle tracking data |
| User profile update |
| 事件 | 描述 |
|---|---|
| 新的运动/活动数据 |
| 新的睡眠会话数据 |
| 身体指标更新 |
| 每日汇总更新 |
| 营养/膳食数据 |
| 经期追踪数据 |
| 用户资料更新 |
Processing Events
处理中事件
| Event | Description |
|---|---|
| Data is being processed |
| Large request in progress |
| Large request sending chunks |
| 事件 | 描述 |
|---|---|
| 数据正在处理中 |
| 大型请求处理中 |
| 大型请求分块推送中 |
Event Payloads
事件负载示例
auth
- User Connected
authauth
- 用户已连接
authjson
{
"type": "auth",
"user": {
"user_id": "terra_abc123",
"provider": "FITBIT",
"reference_id": "user_12345",
"scopes": ["activity", "sleep", "body"]
},
"status": "authenticated"
}json
{
"type": "auth",
"user": {
"user_id": "terra_abc123",
"provider": "FITBIT",
"reference_id": "user_12345",
"scopes": ["activity", "sleep", "body"]
},
"status": "authenticated"
}activity
- Workout Data
activityactivity
- 运动数据
activityjson
{
"type": "activity",
"user": {
"user_id": "terra_abc123",
"provider": "GARMIN",
"reference_id": "user_12345"
},
"data": [{
"metadata": {
"start_time": "2025-12-05T07:00:00Z",
"end_time": "2025-12-05T08:00:00Z",
"type": "running"
},
"calories_data": {
"total_burned_calories": 450
},
"heart_rate_data": {
"summary": { "avg_hr_bpm": 145, "max_hr_bpm": 175 }
},
"distance_data": { "distance_meters": 8500 }
}]
}json
{
"type": "activity",
"user": {
"user_id": "terra_abc123",
"provider": "GARMIN",
"reference_id": "user_12345"
},
"data": [{
"metadata": {
"start_time": "2025-12-05T07:00:00Z",
"end_time": "2025-12-05T08:00:00Z",
"type": "running"
},
"calories_data": {
"total_burned_calories": 450
},
"heart_rate_data": {
"summary": { "avg_hr_bpm": 145, "max_hr_bpm": 175 }
},
"distance_data": { "distance_meters": 8500 }
}]
}sleep
- Sleep Data
sleepsleep
- 睡眠数据
sleepjson
{
"type": "sleep",
"user": {
"user_id": "terra_abc123",
"provider": "OURA"
},
"data": [{
"metadata": {
"start_time": "2025-12-04T22:30:00Z",
"end_time": "2025-12-05T06:30:00Z"
},
"sleep_durations_data": {
"sleep_efficiency": 0.92
},
"asleep": {
"duration_deep_sleep_state_seconds": 5400,
"duration_REM_sleep_state_seconds": 6600
}
}]
}json
{
"type": "sleep",
"user": {
"user_id": "terra_abc123",
"provider": "OURA"
},
"data": [{
"metadata": {
"start_time": "2025-12-04T22:30:00Z",
"end_time": "2025-12-05T06:30:00Z"
},
"sleep_durations_data": {
"sleep_efficiency": 0.92
},
"asleep": {
"duration_deep_sleep_state_seconds": 5400,
"duration_REM_sleep_state_seconds": 6600
}
}]
}daily
- Daily Summary
dailydaily
- 每日汇总
dailyjson
{
"type": "daily",
"user": {
"user_id": "terra_abc123",
"provider": "FITBIT"
},
"data": [{
"metadata": {
"start_time": "2025-12-05T00:00:00Z",
"end_time": "2025-12-05T23:59:59Z"
},
"movement_data": { "steps_count": 10500 },
"calories_data": { "total_burned_calories": 2400 }
}]
}json
{
"type": "daily",
"user": {
"user_id": "terra_abc123",
"provider": "FITBIT"
},
"data": [{
"metadata": {
"start_time": "2025-12-05T00:00:00Z",
"end_time": "2025-12-05T23:59:59Z"
},
"movement_data": { "steps_count": 10500 },
"calories_data": { "total_burned_calories": 2400 }
}]
}deauth
- User Disconnected
deauthdeauth
- 用户已断开
deauthjson
{
"type": "deauth",
"user": {
"user_id": "terra_abc123",
"provider": "FITBIT"
},
"status": "deauthenticated"
}json
{
"type": "deauth",
"user": {
"user_id": "terra_abc123",
"provider": "FITBIT"
},
"status": "deauthenticated"
}Operations
操作指南
setup-webhook-endpoint
setup-webhook-endpointsetup-webhook-endpoint
setup-webhook-endpointCreate a production-ready webhook handler.
python
from flask import Flask, request
from celery import Celery
import hmac
import hashlib
import logging
app = Flask(__name__)
celery = Celery()
logger = logging.getLogger(__name__)
TERRA_SIGNING_SECRET = "your_signing_secret"创建生产可用的Webhook处理器。
python
from flask import Flask, request
from celery import Celery
import hmac
import hashlib
import logging
app = Flask(__name__)
celery = Celery()
logger = logging.getLogger(__name__)
TERRA_SIGNING_SECRET = "your_signing_secret"Terra webhook source IPs (for additional security)
Terra webhook source IPs (for additional security)
TERRA_IPS = [
"18.133.218.210", "18.169.82.189", "18.132.162.19",
"18.130.218.186", "13.43.183.154", "3.11.208.36",
"35.214.201.105", "35.214.230.71", "35.214.252.53", "35.214.229.114"
]
@app.route("/webhooks/terra", methods=["POST"])
def terra_webhook():
# Optional: IP whitelist check
client_ip = request.remote_addr
if client_ip not in TERRA_IPS:
logger.warning(f"Webhook from unknown IP: {client_ip}")
# Consider: return "Forbidden", 403
# Verify signature
signature = request.headers.get("terra-signature")
raw_body = request.get_data()
if not signature or not verify_signature(signature, raw_body):
logger.error("Invalid webhook signature")
return "Invalid signature", 401
# Parse and queue for async processing
payload = request.get_json()
process_webhook.delay(payload)
# Respond immediately (within 5 seconds)
return "OK", 200def verify_signature(header: str, body: bytes) -> bool:
"""HMAC-SHA256 signature verification."""
try:
parts = dict(p.split("=") for p in header.split(","))
timestamp = parts["t"]
signature = parts["v1"]
message = f"{timestamp}.{body.decode()}"
expected = hmac.new(
TERRA_SIGNING_SECRET.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(expected, signature)
except Exception as e:
logger.error(f"Signature verification error: {e}")
return False@celery.task
def process_webhook(payload: dict):
"""Async webhook processing."""
event_type = payload.get("type")
user = payload.get("user", {})
logger.info(f"Processing {event_type} for user {user.get('user_id')}")
handlers = {
"auth": handle_auth,
"deauth": handle_deauth,
"activity": handle_activity,
"sleep": handle_sleep,
"body": handle_body,
"daily": handle_daily,
"nutrition": handle_nutrition,
}
handler = handlers.get(event_type)
if handler:
handler(payload)
else:
logger.warning(f"Unknown event type: {event_type}")undefinedTERRA_IPS = [
"18.133.218.210", "18.169.82.189", "18.132.162.19",
"18.130.218.186", "13.43.183.154", "3.11.208.36",
"35.214.201.105", "35.214.230.71", "35.214.252.53", "35.214.229.114"
]
@app.route("/webhooks/terra", methods=["POST"])
def terra_webhook():
# Optional: IP whitelist check
client_ip = request.remote_addr
if client_ip not in TERRA_IPS:
logger.warning(f"Webhook from unknown IP: {client_ip}")
# Consider: return "Forbidden", 403
# Verify signature
signature = request.headers.get("terra-signature")
raw_body = request.get_data()
if not signature or not verify_signature(signature, raw_body):
logger.error("Invalid webhook signature")
return "Invalid signature", 401
# Parse and queue for async processing
payload = request.get_json()
process_webhook.delay(payload)
# Respond immediately (within 5 seconds)
return "OK", 200def verify_signature(header: str, body: bytes) -> bool:
"""HMAC-SHA256 signature verification."""
try:
parts = dict(p.split("=") for p in header.split(","))
timestamp = parts["t"]
signature = parts["v1"]
message = f"{timestamp}.{body.decode()}"
expected = hmac.new(
TERRA_SIGNING_SECRET.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(expected, signature)
except Exception as e:
logger.error(f"Signature verification error: {e}")
return False@celery.task
def process_webhook(payload: dict):
"""Async webhook processing."""
event_type = payload.get("type")
user = payload.get("user", {})
logger.info(f"Processing {event_type} for user {user.get('user_id')}")
handlers = {
"auth": handle_auth,
"deauth": handle_deauth,
"activity": handle_activity,
"sleep": handle_sleep,
"body": handle_body,
"daily": handle_daily,
"nutrition": handle_nutrition,
}
handler = handlers.get(event_type)
if handler:
handler(payload)
else:
logger.warning(f"Unknown event type: {event_type}")undefinedhandle-data-events
handle-data-eventshandle-data-events
handle-data-eventsProcess incoming health data.
python
def handle_activity(payload: dict):
"""Handle activity/workout data."""
user_id = payload["user"]["user_id"]
for activity in payload.get("data", []):
metadata = activity["metadata"]
unique_key = f"{user_id}:{metadata['start_time']}:{metadata['end_time']}"
# Insert if not exists (activities are unique sessions)
db.activities.update_one(
{"_id": unique_key},
{"$setOnInsert": activity},
upsert=True
)
logger.info(f"Processed activity: {metadata['type']}")
def handle_daily(payload: dict):
"""Handle daily summary data."""
user_id = payload["user"]["user_id"]
for daily in payload.get("data", []):
date = daily["metadata"]["start_time"][:10] # YYYY-MM-DD
# UPSERT - daily data updates multiple times per day
db.daily.update_one(
{"user_id": user_id, "date": date},
{"$set": daily},
upsert=True
)
logger.info(f"Updated daily for {date}")
def handle_sleep(payload: dict):
"""Handle sleep data."""
user_id = payload["user"]["user_id"]
for sleep in payload.get("data", []):
metadata = sleep["metadata"]
unique_key = f"{user_id}:{metadata['start_time']}:{metadata['end_time']}"
db.sleep.update_one(
{"_id": unique_key},
{"$setOnInsert": sleep},
upsert=True
)
def handle_body(payload: dict):
"""Handle body metrics data."""
user_id = payload["user"]["user_id"]
for body in payload.get("data", []):
date = body["metadata"]["start_time"][:10]
# UPSERT - body data updates multiple times per day
db.body.update_one(
{"user_id": user_id, "date": date},
{"$set": body},
upsert=True
)处理传入的健康数据。
python
def handle_activity(payload: dict):
"""Handle activity/workout data."""
user_id = payload["user"]["user_id"]
for activity in payload.get("data", []):
metadata = activity["metadata"]
unique_key = f"{user_id}:{metadata['start_time']}:{metadata['end_time']}"
# Insert if not exists (activities are unique sessions)
db.activities.update_one(
{"_id": unique_key},
{"$setOnInsert": activity},
upsert=True
)
logger.info(f"Processed activity: {metadata['type']}")
def handle_daily(payload: dict):
"""Handle daily summary data."""
user_id = payload["user"]["user_id"]
for daily in payload.get("data", []):
date = daily["metadata"]["start_time"][:10] # YYYY-MM-DD
# UPSERT - daily data updates multiple times per day
db.daily.update_one(
{"user_id": user_id, "date": date},
{"$set": daily},
upsert=True
)
logger.info(f"Updated daily for {date}")
def handle_sleep(payload: dict):
"""Handle sleep data."""
user_id = payload["user"]["user_id"]
for sleep in payload.get("data", []):
metadata = sleep["metadata"]
unique_key = f"{user_id}:{metadata['start_time']}:{metadata['end_time']}"
db.sleep.update_one(
{"_id": unique_key},
{"$setOnInsert": sleep},
upsert=True
)
def handle_body(payload: dict):
"""Handle body metrics data."""
user_id = payload["user"]["user_id"]
for body in payload.get("data", []):
date = body["metadata"]["start_time"][:10]
# UPSERT - body data updates multiple times per day
db.body.update_one(
{"user_id": user_id, "date": date},
{"$set": body},
upsert=True
)handle-auth-events
handle-auth-eventshandle-auth-events
handle-auth-eventsProcess connection lifecycle events.
python
def handle_auth(payload: dict):
"""Handle new user connection."""
user = payload["user"]
# Store Terra user mapping
db.terra_users.insert_one({
"terra_user_id": user["user_id"],
"provider": user["provider"],
"reference_id": user["reference_id"],
"scopes": user.get("scopes", []),
"connected_at": datetime.now(),
"status": "active"
})
# Trigger historical data backfill
trigger_backfill.delay(user["user_id"])
logger.info(f"User connected: {user['user_id']} via {user['provider']}")
def handle_deauth(payload: dict):
"""Handle user disconnection."""
user = payload["user"]
# Mark as disconnected
db.terra_users.update_one(
{"terra_user_id": user["user_id"]},
{"$set": {"status": "disconnected", "disconnected_at": datetime.now()}}
)
logger.info(f"User disconnected: {user['user_id']}")处理连接生命周期事件。
python
def handle_auth(payload: dict):
"""Handle new user connection."""
user = payload["user"]
# Store Terra user mapping
db.terra_users.insert_one({
"terra_user_id": user["user_id"],
"provider": user["provider"],
"reference_id": user["reference_id"],
"scopes": user.get("scopes", []),
"connected_at": datetime.now(),
"status": "active"
})
# Trigger historical data backfill
trigger_backfill.delay(user["user_id"])
logger.info(f"User connected: {user['user_id']} via {user['provider']}")
def handle_deauth(payload: dict):
"""Handle user disconnection."""
user = payload["user"]
# Mark as disconnected
db.terra_users.update_one(
{"terra_user_id": user["user_id"]},
{"$set": {"status": "disconnected", "disconnected_at": datetime.now()}}
)
logger.info(f"User disconnected: {user['user_id']}")verify-signature
verify-signatureverify-signature
verify-signatureSignature verification utility.
python
import hmac
import hashlib
def verify_terra_signature(
signature_header: str,
raw_body: bytes,
signing_secret: str
) -> bool:
"""
Verify Terra webhook signature.
Header format: terra-signature: t=1234567890,v1=abc123...
Args:
signature_header: The terra-signature header value
raw_body: Raw request body (bytes)
signing_secret: Your signing secret from Terra dashboard
Returns:
bool: True if signature is valid
"""
try:
# Parse header
parts = {}
for part in signature_header.split(","):
key, value = part.split("=", 1)
parts[key] = value
timestamp = parts["t"]
signature = parts["v1"]
# Compute expected signature
message = f"{timestamp}.{raw_body.decode()}"
expected = hmac.new(
signing_secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, signature)
except Exception:
return False签名验证工具。
python
import hmac
import hashlib
def verify_terra_signature(
signature_header: str,
raw_body: bytes,
signing_secret: str
) -> bool:
"""
Verify Terra webhook signature.
Header format: terra-signature: t=1234567890,v1=abc123...
Args:
signature_header: The terra-signature header value
raw_body: Raw request body (bytes)
signing_secret: Your signing secret from Terra dashboard
Returns:
bool: True if signature is valid
"""
try:
# Parse header
parts = {}
for part in signature_header.split(","):
key, value = part.split("=", 1)
parts[key] = value
timestamp = parts["t"]
signature = parts["v1"]
# Compute expected signature
message = f"{timestamp}.{raw_body.decode()}"
expected = hmac.new(
signing_secret.encode(),
message.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, signature)
except Exception:
return FalseRetry Logic
重试机制
Terra retries failed webhooks:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | ~30 seconds |
| 3 | ~2 minutes |
| 4 | ~10 minutes |
| 5 | ~30 minutes |
| 6 | ~2 hours |
| 7 | ~8 hours |
| 8 | ~24 hours |
Total: ~8 retries over 24+ hours
Failure conditions:
- Non-2XX response
- Timeout (>5 seconds recommended)
- Connection error
Terra会对失败的Webhook进行重试:
| 重试次数 | 延迟时间 |
|---|---|
| 1 | 立即重试 |
| 2 | ~30秒 |
| 3 | ~2分钟 |
| 4 | ~10分钟 |
| 5 | ~30分钟 |
| 6 | ~2小时 |
| 7 | ~8小时 |
| 8 | ~24小时 |
总计:24小时内最多重试8次
失败条件:
- 返回非2XX状态码
- 超时(建议在5秒内响应)
- 连接错误
Idempotency
幂等性处理
Handle duplicate webhooks safely:
python
def handle_webhook_idempotent(payload: dict):
"""Process webhook with idempotency."""
# Generate idempotency key
user_id = payload["user"]["user_id"]
event_type = payload["type"]
if event_type in ["activity", "sleep"]:
# Session-based: use start+end time
data = payload["data"][0]
key = f"{user_id}:{data['metadata']['start_time']}:{data['metadata']['end_time']}"
elif event_type in ["daily", "body"]:
# Date-based: use date
data = payload["data"][0]
key = f"{user_id}:{data['metadata']['start_time'][:10]}"
else:
# Auth events: use user_id + type + timestamp
key = f"{user_id}:{event_type}:{datetime.now().isoformat()}"
# Check if already processed
if db.processed_webhooks.find_one({"_id": key}):
logger.info(f"Duplicate webhook skipped: {key}")
return
# Process and mark as done
process_event(payload)
db.processed_webhooks.insert_one({"_id": key, "processed_at": datetime.now()})安全处理重复Webhook:
python
def handle_webhook_idempotent(payload: dict):
"""Process webhook with idempotency."""
# Generate idempotency key
user_id = payload["user"]["user_id"]
event_type = payload["type"]
if event_type in ["activity", "sleep"]:
# Session-based: use start+end time
data = payload["data"][0]
key = f"{user_id}:{data['metadata']['start_time']}:{data['metadata']['end_time']}"
elif event_type in ["daily", "body"]:
# Date-based: use date
data = payload["data"][0]
key = f"{user_id}:{data['metadata']['start_time'][:10]}"
else:
# Auth events: use user_id + type + timestamp
key = f"{user_id}:{event_type}:{datetime.now().isoformat()}"
# Check if already processed
if db.processed_webhooks.find_one({"_id": key}):
logger.info(f"Duplicate webhook skipped: {key}")
return
# Process and mark as done
process_event(payload)
db.processed_webhooks.insert_one({"_id": key, "processed_at": datetime.now()})Testing Webhooks
Webhook测试
Local Development with ngrok
使用ngrok进行本地开发
bash
undefinedbash
undefinedInstall ngrok
Install ngrok
npm install -g ngrok
npm install -g ngrok
Start your server
Start your server
python app.py # Running on localhost:5000
python app.py # Running on localhost:5000
Expose with ngrok
Expose with ngrok
ngrok http 5000
ngrok http 5000
Use ngrok URL in Terra dashboard
Use ngrok URL in Terra dashboard
undefinedundefinedTesting with curl
使用curl进行测试
bash
undefinedbash
undefinedSimulate webhook (without signature)
Simulate webhook (without signature)
curl -X POST http://localhost:5000/webhooks/terra
-H "Content-Type: application/json"
-d '{ "type": "activity", "user": {"user_id": "test123", "provider": "FITBIT"}, "data": [{"metadata": {"type": "running"}}] }'
-H "Content-Type: application/json"
-d '{ "type": "activity", "user": {"user_id": "test123", "provider": "FITBIT"}, "data": [{"metadata": {"type": "running"}}] }'
undefinedcurl -X POST http://localhost:5000/webhooks/terra
-H "Content-Type: application/json"
-d '{ "type": "activity", "user": {"user_id": "test123", "provider": "FITBIT"}, "data": [{"metadata": {"type": "running"}}] }'
-H "Content-Type: application/json"
-d '{ "type": "activity", "user": {"user_id": "test123", "provider": "FITBIT"}, "data": [{"metadata": {"type": "running"}}] }'
undefinedWebhook.site Testing
使用Webhook.site测试
- Go to https://webhook.site
- Copy your unique URL
- Add to Terra dashboard as webhook destination
- Connect a test user and observe payloads
- 访问 https://webhook.site
- 复制你的专属URL
- 在Terra控制台中添加该URL作为Webhook目标地址
- 连接测试用户并观察推送的负载内容
IP Whitelisting
控制台配置
Terra webhooks come from these IPs:
python
TERRA_IPS = [
"18.133.218.210",
"18.169.82.189",
"18.132.162.19",
"18.130.218.186",
"13.43.183.154",
"3.11.208.36",
"35.214.201.105",
"35.214.230.71",
"35.214.252.53",
"35.214.229.114"
]- 进入Terra控制台 → 目标地址 → Webhooks
- 添加你的Webhook URL(生产环境必须使用HTTPS)
- 复制签名密钥用于签名验证
- 选择需要接收的事件类型
Dashboard Configuration
相关技能
- Go to Terra Dashboard → Destinations → Webhooks
- Add your webhook URL (must be HTTPS in production)
- Copy the signing secret for signature verification
- Select which events to receive
- terra-auth: 获取签名密钥
- terra-connections: 处理认证/注销事件
- terra-data: 数据结构参考
- terra-troubleshooting: 调试Webhook问题
Related Skills
—
- terra-auth: Get signing secret
- terra-connections: Handle auth/deauth events
- terra-data: Data schema reference
- terra-troubleshooting: Debug webhook issues
—