✈
    TravelmodeDevelopers
    🔎/
    🔑Manage API Keys
    Feature
    🌦️Weather🛂Visa🧩Platform🧭Trips🤖Agent Runs📅Events

    Weather Intelligence API · v1

    📖Overview🧪API Reference (Try It)
    Guides
    🚀Getting Started🔐Authentication⏱️Rate Limits📡Endpoints⚠️Errors🪝Watch Webhooks💳Billing🧊Cache & Freshness📝Attribution
    ⬇️Download openapi.yaml
    Developers / Weather / Watch Webhooks

    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 webhooksPer-watch webhooks
    Where the URL comes fromPOST /v1/webhooks registrationwebhook_url field on the watch
    Signing secretPer-endpoint whsec_... (returned once on creation)Shared account secret — see Signing secret
    Event types deliveredThe full catalog you subscribe toThe four watch event types only
    Retries / backoffShared scheduleSame 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 typeWhen it fires
    weather.change_detectedThe watch's snapshot crossed one of its change_rules thresholds.
    weather.severe_alert_addedA new severe/extreme alert appeared on a snapshot the watch is tracking.
    weather.watch_expiredThe watch reached its ends_at and was retired automatically.
    weather.watch_failedThe 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:

    HeaderValue
    X-Travelmode-SignatureHMAC-SHA256 hex digest of {timestamp}.{raw_body} using the signing secret. Empty string if no secret is configured server-side — reject those.
    X-Travelmode-TimestampUnix-seconds at the moment we signed and dispatched the request.
    X-Travelmode-Event-IdUUID of the underlying change-event row (same as id / data.event_id in the body).
    X-Travelmode-Event-TypeOne 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": {}
      }
    }
    
    FieldNotes
    id / data.event_idUUID of the change-event row. Matches X-Travelmode-Event-Id. Use it to deduplicate retries.
    type / data.event_typeThe event type. Matches X-Travelmode-Event-Type.
    occurred_atISO-8601 timestamp the event was recorded.
    data.severitySeverity band of the change (e.g. info, watch, warning).
    data.reason_codesArray of machine-readable codes describing what changed.
    data.reasonHuman-readable summary (nullable).
    data.recommendationSuggested action (nullable).
    data.watch_idThe watch that produced the event.
    data.snapshot_idThe snapshot that triggered the event (nullable).
    data.previous_snapshot_idThe prior snapshot the new one was compared against (nullable).
    data.payloadEvent-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

    1. Read the raw request body. Do not parse-and-reserialize — the signature covers the exact bytes we sent.
    2. Recompute hex(HMAC-SHA256(secret, timestamp + "." + raw_body)), where timestamp is the X-Travelmode-Timestamp header value.
    3. Compare the result to X-Travelmode-Signature in constant time. Reject on mismatch (and reject an empty signature outright).
    4. 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.
    5. 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 attemptWait before next attempt
      130 seconds
      22 minutes
      310 minutes
      430 minutes
      52 hours

      The total horizon is roughly 2h 42m before the worker gives up.

    • What counts as a failure (and gets retried): a 429, any 5xx, 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 any 3xx redirect. Redirects are never followed (an SSRF protection), so respond with a 2xx directly from the webhook_url you 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 via GET /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).

    Previous
    ← Errors
    Next
    Billing →