Skip to content

Receive alerts as an HTTP call

Register a webhook endpoint, verify the signature, and handle retries and replays.

A webhook endpoint gets an HTTP call from Logable the moment an alert fires or recovers, instead of you polling for one.

Register an endpoint#

Open the organization's Webhooks tab and add an endpoint. You need:

  • A name, so the delivery log reads clearly once you have more than one endpoint.
  • An HTTPS URL. Logable refuses a plain HTTP address.
  • Up to five optional headers sent with every delivery, for example a key your own system checks.

Logable shows the signing secret once, at creation. Copy it somewhere safe. There is no way to see it again, only to rotate it for a new one.

See Send alerts to a webhook for the full walkthrough, including which alert rules to attach the endpoint to.

What you receive#

Logable sends the payload as application/json. Every delivery carries three headers. Read these before you touch the body:

Delivery headers
webhook-id: 6f1e2d3c-4b5a-4978-9c8d-7e6f5a4b3c2d
webhook-timestamp: 1782900000
webhook-signature: v1,K7ZGxV9mP3qL8fWn2cRt5yU6iO1aB4dE7gH0jM3sQ2w=

webhook-id stays the same across every retry of one delivery, so use it to drop a duplicate.

The body for an alert.fired event:

alert.fired payload
{
  "id": "6f1e2d3c-4b5a-4978-9c8d-7e6f5a4b3c2d",
  "type": "alert.fired",
  "api_version": "2026-08-12",
  "created_at": "2026-07-01T10:00:00.000Z",
  "organization_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "data": {
    "alert": {
      "id": "9b2d1f4e-5a6b-7c8d-0d9f-7f523a1c4a6e",
      "severity": "warning",
      "title": "Raw Meal Silo is running low",
      "message": "Weight has fallen below 20000 kg."
    },
    "rule": {
      "id": "3a2b1c0d-9e8f-4a7b-8c6d-5e4f3a2b1c0d",
      "name": "Low silo weight"
    },
    "device": {
      "id": "0d9f7f52-3a1c-4a6e-9b2d-1f4e5a6b7c8d",
      "name": "Raw Meal Silo"
    },
    "reading": {
      "metric": "weight",
      "value": 18750.5,
      "threshold": 20000
    }
  }
}

id matches the webhook-id header. device and stream never both appear: a rule targets one or the other. reading is missing when the rule has no metric, such as an offline condition.

Verify the signature#

Compute the signature yourself and compare it. Do not treat the header's presence alone as proof.

The secret Logable shows you once starts with whsec_. The part after that prefix is base64: decode it to get the 32 raw bytes the platform signs with. Do not HMAC the ASCII text of the whole secret string, prefix included. That produces a signature no receiver ever gets, because Logable never signs that way.

verify.ts
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET_PREFIX = "whsec_";
const SECRET_KEY_BYTES = 32;
const MAX_TIMESTAMP_AGE_SECONDS = 5 * 60;

function secretToKeyBytes(secret: string): Buffer {
  if (!secret.startsWith(SECRET_PREFIX)) {
    throw new Error(`Webhook secret is missing the "${SECRET_PREFIX}" prefix`);
  }
  const bytes = Buffer.from(secret.slice(SECRET_PREFIX.length), "base64");
  if (bytes.length !== SECRET_KEY_BYTES) {
    throw new Error(`Webhook secret must decode to ${SECRET_KEY_BYTES} bytes, got ${bytes.length}`);
  }
  return bytes;
}

function verifyWebhook(input: {
  webhookId: string;
  timestamp: string;
  rawBody: string;
  signatureHeader: string;
  secret: string;
}): boolean {
  const { webhookId, timestamp, rawBody, signatureHeader, secret } = input;

  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (ageSeconds > MAX_TIMESTAMP_AGE_SECONDS) return false;

  const signedContent = `${webhookId}.${timestamp}.${rawBody}`;
  const keyBytes = secretToKeyBytes(secret);
  const expected =
    "v1," +
    createHmac("sha256", keyBytes).update(signedContent, "utf8").digest("base64");
  const expectedBuf = Buffer.from(expected, "utf8");

  return signatureHeader
    .split(" ")
    .some((candidate) => {
      const candidateBuf = Buffer.from(candidate, "utf8");
      if (candidateBuf.length !== expectedBuf.length) return false;
      return timingSafeEqual(candidateBuf, expectedBuf);
    });
}

rawBody is the exact bytes Logable sent. Read it before any JSON.parse, and sign that string, not a re-serialized object: reordering the JSON keys changes the bytes and breaks the signature. secret is the value Logable showed once, when you created the endpoint, whsec_ prefix included. Reject the request if the timestamp is more than five minutes old, which stops a captured request from being replayed later with a valid signature.

The length check before timingSafeEqual matters on its own: that function throws instead of returning false when the two buffers are different lengths, so comparing against a malformed or truncated header would crash your handler instead of just failing the check.

While Logable rotates an endpoint's secret, webhook-signature carries two v1, entries separated by a space, one per active secret. Check the request against both, and accept if either one matches.

Respond#

Return any 2xx status within 10 seconds. Anything else, including a timeout, a connection error, or a redirect, counts as a failed attempt and is retried on the schedule below.

Retries and switch-off#

A failed delivery is retried on a fixed schedule, not on how your endpoint is failing:

Retry schedule
AttemptDelayDescription
Attempt 1immediatelyThe first delivery.
Attempt 25 secondsWaited after attempt 1 failed.
Attempt 35 minutesWaited after attempt 2 failed.
Attempt 430 minutesWaited after attempt 3 failed.
Attempt 52 hoursWaited after attempt 4 failed.
Attempt 65 hoursWaited after attempt 5 failed.
Attempt 710 hoursWaited after attempt 6 failed.
Attempt 810 hoursWaited after attempt 7 failed.

Logable turns an endpoint off by itself once enough deliveries fail in a row:

Automatic switch-off
ConditionValueDescription
consecutive exhausted deliveries5Deliveries that ran the whole retry curve without a 2xx, back to back. Each one is about 28 hours of trying.
consecutive private-address resolutions3The endpoint URL resolved to a private or reserved address. A failed DNS lookup does not count.
days without a success5 daysCounted from the last 2xx while deliveries keep failing.

A success resets both counts to zero, so only a fresh run of failures switches an endpoint off. Logable never turns an endpoint back on by itself. Fix the receiver, send a test event, then enable the endpoint again from the Webhooks tab.

Replays#

Replaying a delivery from the delivery log sends the same payload again under a new id. Your endpoint sees a new delivery, not a retry of the old one. Dedupe on id per delivery, so a replay is stored once, alongside the original.

Test it#

Send a test event from the endpoint's row to check it end to end without waiting for a real alert. A test event carries type: "endpoint.test" with an empty data object, and never counts toward the switch-off counts above.

Search the docs

Find a guide, an integration page, or a device type.