Alerts
Rules, notification channels, signed webhooks and custom payloads.
An alert rule watches one project and fires when its conditions match. Rules live on the project (Alerts in the workspace settings, or the project's own page), and every delivery is recorded in the alert history.
Rules
A rule has:
- A kind and a minimum level:
first_seen— the first event of a new issue,regression— an event on an issue that had been resolved (it is reopened and an activity entry is written),event_count— a number of events (threshold) inside a window (windowMinutes).
- A frequency — how often the rule may fire for the same issue. The cooldown is per issue, so one noisy issue cannot silence the rule for every other.
- Channels — one or more notification channels to deliver to.
- A template — optional
subjectandtextwording for the delivery, which overrides the default text reproduced below.
Channels
| Kind | Configuration |
|---|---|
email | Recipients. Either SMTP (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM) or Plunk (plunkApiKey and plunkDomain on the channel). |
slack | Incoming-webhook URL. Sent as a message with the subject in bold and the body in a code block. |
discord | Webhook URL. Sent as content with the same bold subject and code block. |
webhook | A URL, an optional signing secret, and an optional JSON body template. |
Every channel can be test-fired from the UI or through
POST /organizations/{slug}/projects/{projectSlug}/notification-channels/{channelId}/test.
Delivery
A delivery is attempted up to three times with exponential backoff. Only after the final failure is the rule's cooldown released, so a channel that is briefly down does not swallow the alert.
Webhooks
Requests are POSTed with content-type: application/json and a
user-agent: ErrorDX-Alerts/1.0. Without a body template the full alert goes out:
{
"source": "errordx",
"rule": { "id": "rule-1", "name": "High priority errors" },
"project": { "name": "Web App", "slug": "web" },
"issue": { "id": "issue-1", "shortId": 42, "title": "TypeError: boom", "count": 7 },
"event": {
"id": "abc123",
"level": "error",
"message": "boom",
"culprit": "App.render",
"timestamp": "2026-10-15T18:24:07.000Z"
},
"url": "https://app.errordx.com/acme/issues/issue-1",
"text": "Rule: High priority errors\nProject: Web App\nIssue: TypeError: boom (#42)\n…",
"channelType": "webhook",
"config": { "target": "https://example.com/hook" }
}config is the channel's own configuration as stored, so a receiver can tell two
webhooks apart without asking which is which.
Verifying a signature
With a secret set on the channel, every request carries:
x-errordx-signature: sha256=<hex>where <hex> is the HMAC-SHA256 of the raw request body using that secret.
Verify against the bytes you received, before parsing them:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret: string, rawBody: string, header: string | null) {
if (!header?.startsWith("sha256=")) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const given = header.slice("sha256=".length);
return (
expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given))
);
}A channel with no secret sends no signature header — an unsigned webhook is a choice, not an accident.
Custom bodies
Set a body template on a webhook channel and ErrorDX sends that instead. Templates
substitute {{token}} placeholders:
| Token | Value |
|---|---|
{{rule}} | The rule's name |
{{project}} | Project name |
{{issue}} | Issue title |
{{shortId}} | Issue short id |
{{count}} | Events on the issue |
{{level}} | Issue level |
{{message}} | The triggering event's message |
{{culprit}} | The culprit frame |
{{time}} | When it fired, ISO-8601 |
{{url}} | A link to the issue |
For example, a receiver that wants a different shape:
{
"summary": "[{{level}}] {{issue}}",
"details": "{{message}}\n{{culprit}}",
"link": "{{url}}"
}Two rules make templates predictable:
- The rendered body must be JSON. It is checked when the template is saved, so a broken one is caught where it is written rather than at delivery time, when the alert is already late.
- Values are escaped for their context, so a quote or a newline in an error message cannot produce a body no receiver can parse.
- Unknown tokens are left as written —
{{totl}}arrives as{{totl}}, so a typo shows up in the payload instead of silently becoming empty.
The template is also rendered against a sample payload at save time, which is how a typo in a token name is caught before it ever ships an alert.