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:
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:
{
"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.
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:
| 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#
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.