LogTide

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 to WEBHOOK_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, so a receiver can safely deduplicate on the envelope id.
  • Deduplication is time-bounded — re-enqueueing the same event (same organization, event type and event id) while a delivery for it is still in flight reuses that delivery instead of creating a second one. The window is WEBHOOK_DEDUP_WINDOW_MS (default 60s): anything older, or already delivered or dead-lettered, starts a new delivery. Sources that reuse an event id across occurrences — a monitor that goes down, recovers and goes down again, for example — therefore deliver every occurrence, while a genuine double-enqueue within the window still collapses into one.
  • 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
idstringStable delivery id, prefix evt_ followed by a UUID. Doubles as the dedup id when the producer does not supply one.
typestringEvent type (see table below).
versionnumberSchema version. Currently always 1; the X-Logtide-Event-Version header carries the same value for routing without parsing the body.
occurredAtstringISO 8601 timestamp of when the event occurred.
organizationIdUUIDOrganization that owns the event.
projectIdUUID | nullProject scope. null for org-wide events (e.g. alert rules without a project filter).
dataobjectEvent-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.triggeredAn alert rule or anomaly detection fired. Anomaly detections carry a non-null data.baseline_metadata.
incident.createdA new security incident was created (typically from a Sigma rule detection).
error.detectedAn exception group crossed its notification threshold.
monitor.status_changedAn uptime monitor changed status (e.g. up → down).
channel.testA 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.

Discord Destinations

Discord's webhook endpoint only accepts bodies carrying content, embeds or file parts, and rejects anything else with HTTP 400. A webhook whose URL is a Discord endpoint therefore receives a Discord message instead of the envelope — no channel setting or extra configuration is involved.

Detection covers host discord.com or discordapp.com (including subdomains such as ptb. and canary.) with a path starting /api/webhooks/ or /api/v10/webhooks/. It applies to every source — channel test, monitors, alerts, security incidents, error groups — as well as retries and replays.

Each event becomes a single embed: title and description come from the event's title / message, the color from its severity (green for a monitor recovery), the footer from the organization name, timestamp from the envelope occurredAt, url from the event link, and the remaining per-type values become inline embed fields. Discord's size limits are enforced by truncating values and dropping trailing fields. The monitor.status_changed payload shown above is delivered like this:

{
  "embeds": [
    {
      "title": "Checkout API is down",
      "description": "HTTP 503 from https://api.example.com/health",
      "color": 14427686,
      "url": "https://app.logtide.dev/monitoring/mon_1a2b",
      "timestamp": "2026-08-11T10:15:00.000Z",
      "footer": { "text": "LogTide - Acme" },
      "fields": [
        { "name": "Status", "value": "down", "inline": true },
        { "name": "Target", "value": "https://api.example.com/health", "inline": true },
        { "name": "Error code", "value": "503", "inline": true },
        { "name": "Consecutive failures", "value": "3", "inline": true }
      ]
    }
  ]
}

Want the raw envelope instead?

Append /slack or /github to the Discord webhook URL — those are Discord's own compatibility endpoints, and LogTide leaves the body of such URLs untouched. The delivery log always stores the canonical envelope, so replays and the delivery detail view show the event rather than the Discord payload.

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. For destinations with a vendor-specific body (see Discord destinations) it covers the bytes actually sent, which are the vendor payload rather than the 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_ATTEMPTS5Maximum delivery attempts before the dead-letter queue.
WEBHOOK_PER_ORG_CONCURRENCY5Concurrent deliveries per organization.
WEBHOOK_GLOBAL_CONCURRENCY50Concurrent deliveries across all organizations.
WEBHOOK_DELIVERY_LOG_LIMIT1000Attempts retained per delivery in the delivery log.
WEBHOOK_REQUEST_TIMEOUT_MS10000Per-attempt request timeout in milliseconds.
WEBHOOK_DEDUP_WINDOW_MS60000How long an in-flight delivery suppresses an identical re-enqueue of the same event.
MONITOR_ALLOW_PRIVATE_TARGETSfalseAllow 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.

Esc

Type to search across all documentation pages