Watch Webhooks
Last verified: 2026-06-20 (Task #424 — document the per-watch webhook signature).
A weather watch can carry its own webhook_url (set when you
create the watch). Whenever that watch
produces a deliverable change event, Travelmode POSTs a signed JSON
envelope straight to that URL — you do not need to register a
/v1/webhooks endpoint for it.
This is a separate delivery channel from the account-level Platform webhooks:
| Account-level webhooks | Per-watch webhooks | |
|---|---|---|
| Where the URL comes from | POST /v1/webhooks registration | webhook_url field on the watch |
| Signing secret | Per-endpoint whsec_... (returned once on creation) | Shared account secret — see Signing secret |
| Event types delivered | The full catalog you subscribe to | The four watch event types only |
| Retries / backoff | Shared schedule | Same shared schedule |
Both channels use the same X-Travelmode-* headers and the same
HMAC-SHA256 signing formula, so a single verification routine works
for both — only the secret differs.
Event types
A watch's webhook_url only receives these four event types. Any other
event type (e.g. the account-level weather.webhook_delivery_failed) is
never sent to a watch URL.
| Event type | When it fires |
|---|---|
weather.change_detected | The watch's snapshot crossed one of its change_rules thresholds. |
weather.severe_alert_added | A new severe/extreme alert appeared on a snapshot the watch is tracking. |
weather.watch_expired | The watch reached its ends_at and was retired automatically. |
weather.watch_failed | The refresh worker failed too many times for the watch and gave up. |
Headers
Every per-watch delivery is a POST with Content-Type: application/json
and the following headers:
| Header | Value |
|---|---|
X-Travelmode-Signature | HMAC-SHA256 hex digest of {timestamp}.{raw_body} using the signing secret. Empty string if no secret is configured server-side — reject those. |
X-Travelmode-Timestamp | Unix-seconds at the moment we signed and dispatched the request. |
X-Travelmode-Event-Id | UUID of the underlying change-event row (same as id / data.event_id in the body). |
X-Travelmode-Event-Type | One of the four event types above (same as type / data.event_type in the body). |
Payload envelope
The body is identical in shape to the account-level webhook payload, so the same parser works for both channels:
{
"id": "9d2f5a1c-8b3e-4c7a-9f10-2a4b6c8d0e2f",
"type": "weather.change_detected",
"occurred_at": "2026-05-15T00:15:00.000Z",
"data": {
"event_id": "9d2f5a1c-8b3e-4c7a-9f10-2a4b6c8d0e2f",
"event_type": "weather.change_detected",
"severity": "watch",
"reason_codes": ["precipitation_probability_delta"],
"reason": "Rain chance rose from 25% to 65% for your walking tour.",
"recommendation": "Bring a rain jacket; light rain now expected.",
"watch_id": "1f8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6a",
"snapshot_id": "8a8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e7a",
"previous_snapshot_id": "4e8df5f0-8d9f-4a37-9c1a-9b2b3c4d5e6f",
"payload": {}
}
}
| Field | Notes |
|---|---|
id / data.event_id | UUID of the change-event row. Matches X-Travelmode-Event-Id. Use it to deduplicate retries. |
type / data.event_type | The event type. Matches X-Travelmode-Event-Type. |
occurred_at | ISO-8601 timestamp the event was recorded. |
data.severity | Severity band of the change (e.g. info, watch, warning). |
data.reason_codes | Array of machine-readable codes describing what changed. |
data.reason | Human-readable summary (nullable). |
data.recommendation | Suggested action (nullable). |
data.watch_id | The watch that produced the event. |
data.snapshot_id | The snapshot that triggered the event (nullable). |
data.previous_snapshot_id | The prior snapshot the new one was compared against (nullable). |
data.payload | Event-type-specific extra detail. May be an empty object. |
Signing secret
Per-watch deliveries are signed with a single shared account secret,
not a per-endpoint secret. Server-side it is read from the
WEATHER_WATCH_WEBHOOK_SECRET environment variable, falling back to
SESSION_SECRET when that is unset.
- There is no API call that returns this secret. It is configured out of band by the Travelmode operator who runs your account. Ask them for the value so your handler can verify deliveries.
- If neither environment variable is set, deliveries still go out but
with an empty
X-Travelmode-Signature. A correct handler rejects an empty or mismatched signature, so unsigned calls are safely discarded.
Verifying a delivery
- Read the raw request body. Do not parse-and-reserialize — the signature covers the exact bytes we sent.
- Recompute
hex(HMAC-SHA256(secret, timestamp + "." + raw_body)), wheretimestampis theX-Travelmode-Timestampheader value. - Compare the result to
X-Travelmode-Signaturein constant time. Reject on mismatch (and reject an empty signature outright). - Reject the request if the timestamp drifts more than ~5 minutes from your current clock. This blocks replay of a captured-but-valid delivery even when the signature checks out.
- Treat
X-Travelmode-Event-Id(=data.event_id) as an idempotency key — a retried delivery reuses the same id, so you can ignore one you have already processed.
import crypto from 'node:crypto';
const REPLAY_TOLERANCE_SECONDS = 5 * 60;
/**
* Verify an inbound per-watch weather webhook.
*
* @param rawBody - The exact request body bytes, as a string. Must not
* be re-serialized from a parsed object.
* @param headers - The inbound request headers (lower-cased keys).
* @param secret - WEATHER_WATCH_WEBHOOK_SECRET (or SESSION_SECRET).
* @returns `true` only when the signature matches and the timestamp is
* within the replay-tolerance window.
*/
function verifyWatchWebhook(rawBody, headers, secret) {
const signature = headers['x-travelmode-signature'];
const timestamp = headers['x-travelmode-timestamp'];
if (!signature || !timestamp) return false;
// Reject stale (or future) deliveries to defeat replay attacks.
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(skew) || skew > REPLAY_TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length) return false; // also rejects empty signatures
return crypto.timingSafeEqual(a, b);
}
Delivery, retries, and backoff
-
One attempt fires immediately, then failures retry with exponential backoff over up to 6 total attempts:
After attempt Wait before next attempt 1 30 seconds 2 2 minutes 3 10 minutes 4 30 minutes 5 2 hours The total horizon is roughly 2h 42m before the worker gives up.
-
What counts as a failure (and gets retried): a
429, any5xx, a network error/timeout, or a URL the SSRF guard blocked. -
What is permanent (no retry — fix it on your side): any other
4xx(e.g.400,401,404), and any3xxredirect. Redirects are never followed (an SSRF protection), so respond with a2xxdirectly from thewebhook_urlyou registered. -
Success is any
2xx. Respond quickly (the per-request budget is ~10 seconds) and do heavy work asynchronously. -
Each delivery failure increments the watch's
failure_count, which you can read viaGET /v1/weather/watch/{watch_id}. A successful refresh resets it to zero. -
Once a delivery reaches a terminal state (delivered or gave up) the event is never re-sent on this channel.
URL requirements
The webhook_url must be a valid http(s) URL. HTTPS is strongly
recommended — an empty/garbage signature is the only thing protecting a
plaintext callback in transit. URLs that resolve to private, loopback, or
otherwise internal addresses are rejected by the SSRF guard, both when
the watch is created and again immediately before each delivery (to defeat
DNS-rebinding).