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.
{
"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
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);
}
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))
);
}
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.
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"
]
}'
$titan = new \GuzzleHttp\Client([
'base_uri' => 'https://titanweather.com',
'headers' => ['Authorization' => 'Bearer '.getenv('TITAN_API_TOKEN')],
]);
$response = $titan->post('/api/v1/integrations', [
'json' => [
'name' => 'Claims system',
'channel' => 'webhook',
'url' => 'https://claims.example.com/hooks/titan-weather',
'events' => [
'exposure.detected',
'warning.issued',
'report.completed',
'report.pdf_ready',
],
],
]);
$integration = json_decode((string) $response->getBody(), true);
const response = await fetch('https://titanweather.com/api/v1/integrations', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TITAN_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Claims system',
channel: 'webhook',
url: 'https://claims.example.com/hooks/titan-weather',
events: [
'exposure.detected',
'warning.issued',
'report.completed',
'report.pdf_ready',
],
}),
});
const integration = await response.json();
import os
import requests
response = requests.post(
"https://titanweather.com/api/v1/integrations",
headers={
"Authorization": f"Bearer {os.environ['TITAN_API_TOKEN']}",
},
json={
"name": "Claims system",
"channel": "webhook",
"url": "https://claims.example.com/hooks/titan-weather",
"events": [
"exposure.detected",
"warning.issued",
"report.completed",
"report.pdf_ready",
],
},
)
integration = response.json()
{
"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"
}