Skip to content
All pages

Concepts

Webhooks: storms arrive, nothing polls

Register a destination once and storms come to you: a storm day the hour it enters the record, a live warning as it is issued, a finished report the moment it can be downloaded.

How it fits together

A destination is a URL of yours plus the list of events it subscribes to, managed entirely over the Integrations reference. Webhook destinations receive signed JSON; Slack and Discord destinations receive the same news shaped for their incoming webhooks and authenticate by their address. Every post is recorded in a delivery ledger you can read back, so whether something arrived is never a matter of memory.

Delivery is at-least-once and can arrive out of order. Treat exposure_ids as the durable truth: fetch the records they name rather than trusting a payload cached on your side, and let a repeated delivery overwrite harmlessly.

The event catalogue

Also machine-readable at GET /api/v1/integrations/events, so a settings screen of yours can offer exactly what exists.

exposure.detected A storm day at a watched place Storm days

Every storm day the record enters near a property or inside a territory, the hour it enters. Not filtered by your alert rules.

exposure.escalated A storm day that grew Storm days

A storm day already posted, again, when the measured hail band rose or a tornado arrived.

warning.issued A warning over a watched place Storm days

An official warning in force over something you watch, once per warning. Only on plans with live warnings.

digest.sent The morning digest Digest

The same digest the team receives by email, as one post.

property.created A property saved Properties

The address, its location, contacts and reference: what a CRM files a record from.

property.updated A property changed Properties

When the address, reach, status, contacts or reference change, and when someone presses "Send to CRM".

property.archived A property archived Properties

When a property stops being watched.

report.completed A report finished Reports

A finished report: the link, the window, what it found, and the property it is filed under. The PDF follows separately.

report.pdf_ready A report's PDF is ready Reports

The report's PDF, rendered and stored: the same report again with pdf.status ready and the links to fetch the file. Posted once the photographs are in, a little after the report itself.

The payload

Every post carries the event name, the team, a headline a person could read aloud, and the ids of the activity behind it. Fields are added and never repurposed, exactly as in the API's own responses.

The signature

Webhook posts carry X-Titan-Signature: an HMAC-SHA256 of {timestamp}.{raw body} under your destination's signing secret, sent as t=<unix>,v1=<hex>. Verify against the raw request body before parsing it, compare with a constant-time function, and refuse a timestamp more than 300 seconds from now to keep a captured post from being replayed later.

The secret is returned once at registration and readable under integrations:write. Rotation is immediate: the old secret stops verifying the moment the new one is issued, so update the receiver first.

Answer fast, work later

Answer 2xx as soon as the signature checks, then process on your own queue. A destination that fails repeatedly is recorded as failing in the ledger; a test post through POST /api/v1/integrations/{id}/test answers synchronously with what your receiver said, which is the whole point of it.

A delivery, on the wire 200
{
    "kind": "alert",
    "event": "exposure.detected",
    "team_name": "North Texas Claims Group",
    "subject": "Storm day detected at Cedar Ridge",
    "headline": "Hail to 2.75 in measured 0.8 miles away.",
    "lede": "A storm day entered the record at 1 watched property.",
    "rows": [
        "Cedar Ridge: hail to 2.75 in, 0.8 miles away, measured on the ground."
    ],
    "exposure_ids": [
        48213
    ],
    "activity_url": "https://titanweather.com/dashboard",
    "settings_url": "https://titanweather.com/settings",
    "object": "alert",
    "data": [],
    "facts": [],
    "link": null,
    "link_label": null
}

Verifying, in your language

PHP
function verifyTitanSignature(string $header, string $body, string $secret): bool
{
    parse_str(str_replace(',', '&', $header), $parts);
    $timestamp = (int) ($parts['t'] ?? 0);
    $signature = (string) ($parts['v1'] ?? '');

    if (abs(time() - $timestamp) > 300) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$body, $secret);

    return hash_equals($expected, $signature);
}
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyTitanSignature(header, body, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const timestamp = Number(parts.t);
  const signature = parts.v1 ?? '';

  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  return (
    signature.length === expected.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
  );
}
Python
import hashlib
import hmac
import time

def verify_titan_signature(header: str, body: str, secret: str) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    timestamp = int(parts.get("t", 0))
    signature = parts.get("v1", "")

    if abs(time.time() - timestamp) > 300:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.{body}".encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Worked: registering a destination

The one response that carries the signing secret. Store it like a password; it is what makes a receiver believe us.

Register a destination

POST /api/v1/integrations

Register a destination. The signing secret is returned once, here.

integrations:write

Body

name string required

What the destination is called in the delivery ledger.

channel string required

Where posts go: webhook (signed JSON to your endpoint), slack or discord (an incoming webhook URL of theirs).

url string required

The address to post to. Never read back; responses show a redacted hint instead.

events array required

What to post, from GET /api/v1/integrations/events. At least one.

active boolean

Whether the destination is switched on. Defaults to true.

Answers

201

Created. The signing secret is in this response and nowhere else.

409

The destination cap is reached. No tier lifts it; remove one to add another.

Plus the shared refusals: 401, 402, 403, 404, 422 and 429, described on Errors.

POST /api/v1/integrations
curl -X POST https://titanweather.com/api/v1/integrations \
  -H "Authorization: Bearer $TITAN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Claims system",
    "channel": "webhook",
    "url": "https://claims.example.com/hooks/titan-weather",
    "events": [
        "exposure.detected",
        "warning.issued",
        "report.completed",
        "report.pdf_ready"
    ]
}'
Response 201
{
    "data": {
        "id": "0198d4e6-2c58-7a19-8f03-7b9e5d21a4c7",
        "object": "integration",
        "name": "Claims system",
        "channel": "webhook",
        "channel_label": "Webhook",
        "address_hint": "https://claims.example.com/...weather",
        "events": [
            "exposure.detected",
            "warning.issued",
            "report.completed",
            "report.pdf_ready"
        ],
        "active": true,
        "signed": true,
        "last_sent_at": "2026-08-23T13:51:02Z",
        "last_failed_at": null,
        "last_failure": null,
        "created_at": "2026-07-14T10:00:00Z",
        "updated_at": "2026-08-23T13:51:02Z",
        "links": {
            "self": "https://titanweather.com/api/v1/integrations/0198d4e6-2c58-7a19-8f03-7b9e5d21a4c7",
            "deliveries": "https://titanweather.com/api/v1/integrations/0198d4e6-2c58-7a19-8f03-7b9e5d21a4c7/deliveries"
        }
    },
    "secret": "whsec_6b0a4f1d92e8735a6c410b9f2d8e07c5"
}
esc
move open 72 places