Appearance
The generic webhook kind is the lowest-level destination ErrLens offers: an HTTPS POST with a JSON body and an HMAC-SHA256 signature header. Anything that speaks HTTP can be a receiver — your own service, a serverless function, an existing internal alerting bus.
This is the original ErrLens alert path. The named kinds (Slack / Discord / Teams / n8n / email) are just opinionated formatters on top of this same machinery.
{
"event": "issue.first_seen",
"delivery_id": "fb1f...-uuid",
"occurred_at": "2026-05-13T09:00:00Z",
"project": {
"id": "uuid",
"slug": "checkout-api",
"name": "checkout-api",
"platform": "go"
},
"issue": {
"id": "uuid",
"title": "panic: runtime error: invalid memory address",
"culprit": "handlers/charge.go:142",
"level": "fatal",
"environment": "prod",
"release": "checkout-api@4.2.1",
"times_seen": 1,
"first_seen": "2026-05-13T09:00:00Z",
"url": "https://acme.errlens.dev/projects/checkout-api/issues/uuid"
}
}event ∈ issue.first_seen | project.frequency. Schema is identical across triggers.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | errlens-webhook/<version> |
X-ErrLens-Event | same as the event field |
X-ErrLens-Delivery | the delivery's UUID — use for idempotency |
X-ErrLens-Kind | generic_webhook |
X-ErrLens-Signature | sha256=<hex> — HMAC-SHA256 of the raw body |
The signature is HMAC-SHA256 over the raw request body bytes using the destination's signing secret. Constant-time-compare on the receiver side. Examples:
import crypto from "node:crypto";
function verify(secret, rawBody, sigHeader) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return expected.length === sigHeader.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader));
}func verify(secret string, body []byte, sig string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}import hmac, hashlib
def verify(secret: str, body: bytes, sig: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, sig)The dispatcher uses River as the queue, with exponential backoff:
| Status | What happens |
|---|---|
2xx | Success. Counters reset. |
4xx (not 408/429) | Permanent. Job cancelled, no retry. |
408, 429, 5xx | Transient. Retried with backoff up to 6 attempts. |
| Network error / timeout | Transient. Retried with backoff. |
Idempotency: every delivery carries a unique X-ErrLens-Delivery UUID. The same delivery may be retried multiple times after a transient failure — receivers that mutate state should dedupe on this header.
Pick generic when:
Pick a named kind (Slack / Discord / Teams / Email) when: