Webhooks
LogTide delivers events — alerts, anomalies, errors, monitor status changes, security incidents, and notification-channel webhooks — to your endpoints through a centralized dispatcher with SSRF protection, retry with exponential backoff, a dead-letter queue, and delivery logging.
Overview
Every outbound delivery — whether it originates from an alert rule, an uptime monitor, an error group, a security incident, or a notification channel — flows through one well-tested dispatcher. That gives every webhook the same guarantees:
Signed payloads
Optional HMAC-SHA256 signature so you can verify each delivery came from LogTide untampered.
Automatic retries
Transient failures are retried with exponential backoff before being parked.
Dead-letter queue
Exhausted deliveries are kept and can be replayed from the dashboard.
SSRF protection
Targets are resolved and validated on every request and every redirect hop.
Delivery Semantics
- Retries — transient failures (network errors, timeouts, HTTP 5xx, 429) are retried with exponential backoff (
1s, 5s, 25s, 2m, 10m), up toWEBHOOK_MAX_ATTEMPTS(default 5). Other 4xx responses and SSRF-blocked targets are terminal and not retried. - Dead-letter queue — after the final failed attempt a delivery is marked
dead. Dead and failed deliveries can be replayed from Settings → Webhook Deliveries. - Idempotency — each delivery carries a stable id, and the same logical event is never enqueued twice. A receiver can safely deduplicate on the envelope
id. - Timeout — each attempt times out after
WEBHOOK_REQUEST_TIMEOUT_MS(default 10s).
Return 2xx quickly
Acknowledge the delivery with a 2xx as soon as you have persisted it, then do your work asynchronously. A slow handler that exceeds the request timeout is treated as a failed attempt and will be retried.
Event Envelope
Every delivery body is a JSON object with a top-level envelope. The data field
carries the event-specific payload.
{
"id": "evt_4b3e1a2c-8f7d-4e6b-9c0a-1d2e3f4a5b6c",
"type": "alert.triggered",
"version": 1,
"occurredAt": "2026-06-11T14:32:07.841Z",
"organizationId": "9f8e7d6c-5b4a-3c2d-1e0f-a1b2c3d4e5f6",
"projectId": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d",
"data": {
"alert_name": "Error rate spike",
"log_count": 142,
"threshold": 50,
"time_window": 300,
"baseline_metadata": null,
"link": "https://app.logtide.dev/alerts/history/abc123"
}
} Envelope fields
| Field | Type | Description |
|---|---|---|
id | string | Stable delivery id, prefix evt_ followed by a UUID. Doubles as the dedup id when the producer does not supply one. |
type | string | Event type (see table below). |
version | number | Schema version. Currently always 1; the X-Logtide-Event-Version header carries the same value for routing without parsing the body. |
occurredAt | string | ISO 8601 timestamp of when the event occurred. |
organizationId | UUID | Organization that owns the event. |
projectId | UUID | null | Project scope. null for org-wide events (e.g. alert rules without a project filter). |
data | object | Event-specific payload. See per-type fields below. |
Validating with @logtide/shared
The envelope and per-type data schemas ship as Zod schemas, so a TypeScript receiver can parse and validate in one step:
import { webhookEnvelopeSchema, parseWebhookEvent } from '@logtide/shared';
// Parse the envelope only (data stays Record<string, unknown>)
const envelope = webhookEnvelopeSchema.parse(body);
// Parse the envelope AND validate the per-type data schema
const event = parseWebhookEvent(body); parseWebhookEvent throws a Zod ZodError if either the envelope shape
or the per-type data fields are invalid.
Event Types
| Type | Description |
|---|---|
alert.triggered | An alert rule or anomaly detection fired. Anomaly detections carry a non-null data.baseline_metadata. |
incident.created | A new security incident was created (typically from a Sigma rule detection). |
error.detected | An exception group crossed its notification threshold. |
monitor.status_changed | An uptime monitor changed status (e.g. up → down). |
channel.test | A test delivery sent when verifying a notification channel. |
Each type's data payload keeps the snake_case fields you may already consume,
minus the legacy event_type / timestamp keys (now type
and occurredAt on the envelope). The most common shapes:
alert.triggered data
{
"alert_name": "Error rate spike",
"log_count": 142,
"threshold": 50,
"time_window": 300,
"baseline_metadata": {
"baseline_value": 30,
"current_value": 142,
"deviation_ratio": 4.7,
"baseline_type": "rate_of_change",
"evaluation_time": "2026-06-11T14:32:07.841Z"
},
"link": "https://app.logtide.dev/alerts/history/abc123"
} baseline_metadata is null for plain threshold rules and non-null
for anomaly / rate-of-change detections.
error.detected data
{
"title": "TypeError: cannot read property 'id' of undefined",
"message": "TypeError: cannot read property 'id' of undefined",
"severity": "high",
"organization": { "id": "9f8e...", "name": "Acme" },
"project": { "id": "3a4b...", "name": "api" },
"error_group_id": "grp_8d2f...",
"exception_type": "TypeError",
"language": "nodejs",
"service": "checkout",
"is_new": true,
"link": "https://app.logtide.dev/errors/grp_8d2f"
} monitor.status_changed data
{
"monitor_id": "mon_1a2b...",
"monitor_name": "Checkout API",
"status": "down",
"severity": "critical",
"title": "Checkout API is down",
"message": "HTTP 503 from https://api.example.com/health",
"organization": { "id": "9f8e...", "name": "Acme" },
"target": "https://api.example.com/health",
"error_code": "503",
"response_time_ms": null,
"consecutive_failures": 3,
"downtime_duration": null,
"link": "https://app.logtide.dev/monitoring/mon_1a2b"
} incident.created and channel.test
incident.created carries title, message,
severity, organization, incident_id, optional
affected_services, and link. channel.test carries
a title / message with optional severity,
organization, link and a free-form metadata map.
Verifying Signatures
When a signing secret is configured for a webhook, LogTide signs each delivery so you can verify it came from LogTide and was not tampered with. Two headers are added:
X-Logtide-Timestamp: <unix seconds>X-Logtide-Signature: t=<unix>,v1=<hex hmac>
The signature is HMAC-SHA256(secret, "<timestamp>.<raw request body>"),
hex-encoded, and covers the serialized envelope.
import crypto from 'crypto';
function verify(secret, headers, rawBody) {
const ts = headers['x-logtide-timestamp'];
const sig = headers['x-logtide-signature'].split('v1=')[1];
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(sig, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
} Verify against the raw body
Compute the HMAC over the raw request body (before JSON parsing) — any reserialization can change the bytes and break the signature. Reject deliveries whose timestamp is older than your tolerance (e.g. 5 minutes) to prevent replay.
SSRF Protection
Outbound targets are resolved and validated before each request. Loopback, private, link-local (including cloud metadata), CGNAT, multicast, and reserved IPv4/IPv6 ranges are rejected, and every redirect hop is revalidated.
Self-hosted deployments that need to reach internal endpoints can opt in by setting
MONITOR_ALLOW_PRIVATE_TARGETS=true.
Replaying Deliveries
Every delivery and each of its attempts are recorded in a bounded delivery log, browsable under Settings → Webhook Deliveries with a status filter. Failed and dead-lettered deliveries can be replayed from there.
Historical replays
Deliveries created before the envelope change re-send their stored pre-envelope payload
when replayed, while still carrying the X-Logtide-Event-Version header.
Treat the header as advisory for replayed historical deliveries.
Configuration
| Variable | Default | Description |
|---|---|---|
WEBHOOK_MAX_ATTEMPTS | 5 | Maximum delivery attempts before the dead-letter queue. |
WEBHOOK_PER_ORG_CONCURRENCY | 5 | Concurrent deliveries per organization. |
WEBHOOK_GLOBAL_CONCURRENCY | 50 | Concurrent deliveries across all organizations. |
WEBHOOK_DELIVERY_LOG_LIMIT | 1000 | Attempts retained per delivery in the delivery log. |
WEBHOOK_REQUEST_TIMEOUT_MS | 10000 | Per-attempt request timeout in milliseconds. |
MONITOR_ALLOW_PRIVATE_TARGETS | false | Allow delivery to private/internal addresses (self-hosted). |
Want to observe, enrich, or reject deliveries before they leave the backend? See the
Lifecycle Hooks guide — the
beforeWebhookDispatch and afterWebhookDispatch phases hook directly
into this dispatcher.