# Receive alerts as an HTTP call (https://logable.app/docs/integrate/webhooks-out)

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 [#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](/docs/guides/alerts/send-alerts-to-a-webhook) for the full walkthrough, including which alert rules to attach the endpoint to.

## What you receive [#what-you-receive]

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

```http title="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:

```json title="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 [#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.

```ts title="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 [#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 [#retries-and-switch-off]

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

| Attempt | Delay | Description |
| --- | --- | --- |
| Attempt 1 | immediately | The first delivery. |
| Attempt 2 | 5 seconds | Waited after attempt 1 failed. |
| Attempt 3 | 5 minutes | Waited after attempt 2 failed. |
| Attempt 4 | 30 minutes | Waited after attempt 3 failed. |
| Attempt 5 | 2 hours | Waited after attempt 4 failed. |
| Attempt 6 | 5 hours | Waited after attempt 5 failed. |
| Attempt 7 | 10 hours | Waited after attempt 6 failed. |
| Attempt 8 | 10 hours | Waited after attempt 7 failed. |

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

| Condition | Value | Description |
| --- | --- | --- |
| consecutive exhausted deliveries | 5 | Deliveries that ran the whole retry curve without a 2xx, back to back. Each one is about 28 hours of trying. |
| consecutive private-address resolutions | 3 | The endpoint URL resolved to a private or reserved address. A failed DNS lookup does not count. |
| days without a success | 5 days | Counted 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 [#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 [#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.

## Read next [#read-next]

* [Send alerts to a webhook](/docs/guides/alerts/send-alerts-to-a-webhook)
* [Webhook events](/docs/reference/webhook-events)
* [Limits](/docs/reference/limits)
