Webhooks
Last verified: 2026-06-20 (Task #388 — multi-feature portal restructure).
Webhooks let your service react to platform events without polling. Register an HTTPS endpoint, choose the events you care about, and the delivery worker POSTs signed payloads as those events fire.
Webhook management is a Platform endpoint (/v1/webhooks), but the
event catalog is product-specific. The MVP ships the Weather event
types below; future features will publish their own event types against
the same webhook machinery.
Event types
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.ai/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.
Creating a webhook requires the weather:webhooks scope.
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 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 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 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.)