Webhooks
Last verified: 2026-05-03 (Task #334 — docs accuracy pass).
Webhooks let your service react to weather changes without polling. Register an HTTPS endpoint, choose the events you care about, and the delivery worker POSTs signed payloads as the watches you've created cross their thresholds.
Event types
The MVP supports the events below. Subscribing to an event you don't need is harmless — just drop it on receipt.
| Event type | When it fires |
|---|---|
weather.change_detected | A 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 | A watch reached its ends_at and was retired automatically. |
weather.watch_failed | The refresh worker failed too many times for a watch and gave up. |
weather.snapshot_refreshed | A watch's snapshot was refreshed even though no rule was crossed. Useful for "always emit" pipelines. |
weather.webhook_delivery_failed | Terminal state for a delivery — the worker exhausted retries and gave up on the endpoint. |
weather.webhook_endpoint_disabled | Auto-disable safeguard tripped: the endpoint hit the consecutive-failure or rolling-give-up threshold and was transitioned to status='disabled'. Fires exactly once per disable. |
The events field on POST /v1/webhooks is a non-empty subset of
this list.
Creating an endpoint
curl -s -X POST "https://api.travelmode.app/v1/webhooks" \
-H "Authorization: Bearer tm_weather_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/weather",
"events": ["weather.change_detected", "weather.severe_alert_added"],
"description": "Production weather notifier"
}'
The response includes a secret — only on creation. It looks like
whsec_<32 hex chars>. Store it somewhere your handler can read.
Subsequent reads (GET /v1/webhooks, GET /v1/webhooks/{id}) only
return the public secret_prefix.
Verifying signatures
Every delivery includes three headers:
| Header | Value |
|---|---|
X-Travelmode-Signature | HMAC-SHA256 hex digest of {timestamp}.{raw_body} using your endpoint's secret. |
X-Travelmode-Timestamp | Unix-seconds at the moment we signed and dispatched the request. |
X-Travelmode-Event-Id | UUID of the underlying weather_change_event row. |
To verify a delivery:
- Read the raw request body (do not parse-and-reserialize — bytes matter).
- Recompute
hex(HMAC-SHA256(secret, timestamp + "." + raw_body)). - Compare in constant time to the value of
X-Travelmode-Signature. - Reject the request if the timestamp drift is more than ~5 minutes
from
now. This blocks replay attacks even if the signature matches.
import crypto from 'node:crypto';
function verify(req, secret) {
const sig = req.headers['x-travelmode-signature'];
const ts = req.headers['x-travelmode-timestamp'];
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(sig, 'hex'),
);
}
Retry behavior
Failed deliveries (any non-2xx response, or a network error) are
retried with exponential backoff. Each attempt is recorded in
webhook_delivery_attempts with response status, response time,
response body excerpt, and the signature/timestamp pair we sent. The
attempts are queryable via GET /v1/webhooks/{id}/attempts.
Once retries are exhausted the worker emits a
weather.webhook_delivery_failed event for the same endpoint, so you
can be notified about your own unreachable URLs.
Required scope
All /v1/webhooks endpoints (and the attempts endpoint) require the
weather:webhooks scope.
Required URL scheme
Webhook URLs must use HTTPS. The create endpoint rejects
http:// URLs at validation time — we never send signed payloads
over plaintext.
Disabling an endpoint
DELETE /v1/webhooks/{id} soft-disables the endpoint
(status='disabled', disabled_at=now). The delivery worker stops
targeting it; the row itself is retained so historical attempts stay
queryable.
Auto-disable safeguard
To stop a persistently-broken endpoint from burning worker capacity forever, the delivery worker auto-disables an endpoint when either of the following holds true:
- 50 consecutive failed deliveries. The
failure_countcolumn is bumped on every non-2xx attempt and reset to 0 on every success, so this fires only when there's been no recovery in between. - 6 retry chains exhausted (
giving_upattempts) in a rolling 24h window. Counts terminal failures across separate events, so a single noisy day still trips the safeguard.
When the safeguard fires:
- The endpoint transitions to
status='disabled'withdisabled_at=nowanddisabled_reasonpopulated (one ofauto_disabled_consecutive_failuresorauto_disabled_giveup_window). - A final
weather.webhook_endpoint_disabledevent is emitted with the reason, the threshold counts, and the originalwebhook_idin its payload. Subscribe to it on a different webhook so the notification actually lands. - The worker stops dispatching new events to the endpoint.
Re-enable by POST /v1/webhooks with the corrected URL after fixing
your endpoint — the call mints a fresh secret and a fresh endpoint
row. (The original disabled row is retained for audit history.)