Notifications
Where Unhandled sends notifications, how channels, defaults, exception rules, email preferences and batching work, and the webhook format.
unhandled tells you about problems in three places: the in-app inbox, email, and notification channels (Slack, Discord or your own webhook). This page explains who receives what, how to route exceptions per service and environment, and how to receive and verify webhooks.
What goes where
| Event | Inbox | Channels | |
|---|---|---|---|
| New issue | All project members | Project members with Exception summaries on, batched | Per the project's exception rules, batched |
| Regressed issue | no | Project members with Exception summaries on, batched | Per the project's exception rules, batched |
| Issue assigned to you | yes | yes | no |
| Status change on an issue you are assigned to | yes | With Issue status changes on | no |
| Comment on an issue you are assigned to | yes | With Issue comments on | no |
| Monitor down or recovered | All project members | Project members with Uptime alerts on | Channels with Uptime monitors as a default, or the channels picked on the monitor |
| TLS certificate expiring (14, 7 and 1 days before) | All project members | Project members with Uptime alerts on | no |
| Alert firing or resolved | Project members, if the alert notifies App members | Same, with Endpoint alerts on | Only the channels selected on the alert |
| Weekly recap | no | Project members with Weekly recaps on | no |
"Project members" means admins plus every member of a team that has the project.
Inbox
The bell in the top bar shows how many notifications you have not seen yet. Press i to open it. Inside, j and k move between items and e marks the focused item as read. Opening the inbox clears the badge; opening an item takes you to its issue, monitor or alert and marks it read. Mark all read clears everything.
Notifications are grouped under Today, Yesterday and Earlier. Filter by Issues, Uptime, Alerts or Feedback, or show unread items only. The inbox shows your 50 most recent notifications in the current organization.
Notification channels
Channels belong to the organization and are shared by all its projects. Admins manage them under Settings > Integrations (/o/:org/settings/integrations). Every member can see them.
A channel has a type, a name of up to 80 characters and a destination URL:
- Slack: an incoming webhook URL. See Slack.
- Discord: a channel webhook URL. See Discord.
- Webhook: any HTTPS endpoint you control. Unhandled sends JSON and signs every request.
The type cannot change after creation. The URL is write-only: Unhandled stores it encrypted and shows only the host and the last characters of the path. To change it, paste a new one.
Use Send test to post a test message, either from the create form before saving or from a saved channel. A test from the create form signs with a throwaway secret.
Each channel page lists its usage (alerts, monitors and exception rules that post to it) and a delivery log of its last 100 messages with status, attempts, response code and last error. The statuses are:
| Status | Meaning |
|---|---|
| Pending | Waiting for its first attempt, its next retry, or the end of an alert snooze. |
| Delivered | The channel answered with a 2xx. |
| Failed | Every retry failed, or the channel rejected it for good (for example a 404). Failed rows are kept so you can see what was lost. |
| Grouped | Sent together with other notifications as one message, shown by its number. |
| Superseded | Not sent because it no longer applies: a firing message that was still retrying when its incident resolved, an alert message held while the incident was acknowledged or snoozed until resolved, or an email to a status page subscriber who unsubscribed. |
Channel health
A channel that fails 10 delivery attempts in a row is disabled. Unhandled stops sending to it, shows Disabled on the channel and in the channel list, and tells every organization admin once, in the inbox and by email. A channel that has failed fewer times shows Failing with its last error and when it last delivered. Send a test that succeeds, or paste a new URL, to turn a disabled channel back on.
URL rules
Destination URLs must use https://, cannot contain credentials or a #fragment, and must resolve to public IP addresses only. Unhandled checks this when you save the channel and again before every delivery, so a DNS change cannot point an existing channel at an internal service. A self-hosted instance can exempt hosts with the NOTIFICATIONS_TRUSTED_HOSTS environment variable, a comma-separated list.
Defaults
Under Use by default, each channel has three checkboxes:
- Uptime monitors: post monitor down and recovered events.
- Exceptions: post new and regressed issues that no exception rule matches.
- Only for production environments: apply both defaults to production only.
Monitors use the uptime defaults unless their Default channels switch is off, in which case they post only to the channels picked on the monitor. Alerts never use defaults.
Deleting a channel
Deleting a channel removes the exception rules that post to it, so the next matching rule takes over. Alerts that posted to it keep being evaluated, and monitors stop notifying it.
Exception rules
Exception rules decide which channels hear about new and regressed issues in a project. Configure them on the Exceptions tab of a project's Alerts page (/o/:org/projects/:project/alerts/exceptions). The old settings/exception-alerts URL redirects there. Admins can edit rules; members can view them.
A rule matches a service (or any service) and an environment (or any environment), and either posts to one channel or mutes one channel type with Mute Slack, Mute Discord or Mute webhooks.
Each channel type is resolved on its own. For a given issue, Unhandled takes the matching rules for that type and keeps the most specific ones:
- Service and environment.
- Service only.
- Environment only.
- Any service, any environment.
If any of the most specific rules is a mute, nothing is posted for that type. Otherwise every channel among them gets the issue. When no rule matches, the Everything else row applies: the channels marked as exception defaults, or Not posted if there are none.
For example, a rule that posts shop-api production issues to #checkout-oncall, plus a rule that mutes Slack for staging, keeps staging noise out of Slack while Discord and webhooks still follow their own rules.
Batching
Exception notifications are grouped so a burst of errors produces one message, not fifty.
| Channels | ||
|---|---|---|
| Collection window | 60 seconds per channel | 60 seconds per person |
| Issues listed | up to 10, plus the total | up to 30 |
| Minimum gap | none | 5 minutes between emails |
Issues left over from a full email go out in the next one. An issue that was resolved or archived before the batch went out is dropped from it.
Email preferences
Each person sets their own email preferences per organization under Account > Email notifications (/account/notifications), which lists every organization they belong to: Uptime alerts, Endpoint alerts, Weekly recaps, Exception summaries, Issue comments and Issue status changes. All six are on by default and affect email only. Each of these emails has an unsubscribe link (and a List-Unsubscribe header) that turns off its preference without signing in. Issue assignment emails, invitations and password resets are always sent.
The same page has an Inbox section with a switch per category (Issues, Uptime, Alerts and Feedback). Turning a category off keeps new notifications of that kind out of your inbox. Channels are unaffected.
Weekly recap
Every Monday at 08:00 UTC Unhandled emails a recap of each project that served HTTP traffic in production over the past 7 days. It covers the request count, error rate, average response time, the top 5 exceptions and the 5 slowest endpoints by p95.
Webhooks
Webhook channels receive a JSON envelope:
{
"type": "uptime.down",
"timestamp": "2026-10-21T09:14:03.000Z",
"data": {
"monitorId": "6a1f0c52-8a4e-4c1e-9d3b-2f7e5a9b1c40",
"monitorName": "Storefront",
"monitorUrl": "https://shop.example.com/health",
"environmentName": "production",
"applicationName": "Shop",
"organizationSlug": "acme",
"url": "https://unhandled.example.com/o/acme/projects/shop/monitors/6a1f0c52-8a4e-4c1e-9d3b-2f7e5a9b1c40",
"errorType": "timeout",
"downAt": "2026-10-21T09:14:00.000Z"
}
}Event types
| Type | data fields |
|---|---|
uptime.down | incidentId, dedupKey, monitorId, monitorName, monitorUrl, environmentName, applicationName, organizationSlug, url, errorType (timeout, dns, connection, tls, http, ssrf, keyword, heartbeat or unknown), downAt |
uptime.recovered | The monitor fields, recoveredAt, and downAt (null when unknown) |
alert.firing | incidentId, dedupKey, ruleId, ruleName, metric, comparator, threshold, windowMinutes, method, route, serviceName, applicationName, environmentName, organizationSlug, alertUrl, endpointUrl, slowestTracesUrl, startedAt, value, samples |
alert.reminder | The same rule fields, value (the latest) and peakValue |
alert.resolved | The same rule fields without value and samples, plus resolvedAt, peakValue and reason (recovered, no_data, rule_edited, rule_paused or rule_deleted) |
alert.error | ruleId, ruleName, applicationName, environmentName, organizationSlug, alertUrl, failures, failedAt |
exception.issues | total, organizationName, and issues: up to 10 entries with title, application, environment, firstAppearance, regression, url |
test | organizationName |
dedupKey is the same on every event of one incident (alert:<ruleId>:<incidentId> or uptime:<monitorId>:<incidentId>), so a receiver that bridges to a paging tool can use it as the incident key.
Delivery
Every request is a POST with these headers:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | Unhandled-Webhooks/1.0 |
webhook-id | A unique message ID. Retries reuse it, so you can deduplicate. |
webhook-timestamp | Unix time in seconds. |
webhook-signature | v1, followed by the base64 signature. |
A 2xx response within 10 seconds counts as delivered. Redirects are not followed and count as a failure.
Every delivery except a test is retried after a timeout, a network error, 408, 429 or a 5xx:
| Events | Attempts | Waits between attempts |
|---|---|---|
| Uptime and alerts | 7 | 10 s, 30 s, 2 min, 5 min, 15 min, 30 min |
exception.issues | 6 | 30 s, 1 min, 2 min, 4 min, 8 min |
Each wait varies by up to 20 % so retries from many channels don't arrive together. A Retry-After header is honoured up to twice the scheduled wait. Other 4xx responses fail at once.
Messages for one incident arrive in order: a resolved message waits until the firing message before it is delivered or has failed for good. If the firing message is still retrying when the incident resolves, it is dropped and the resolved message goes out.
Snoozing an alert or acknowledging its incident holds its channel and email notifications at delivery:
- A timed snooze (1 hour, 4 hours, or until 09:00 tomorrow in your time zone) keeps the firing message pending. When the snooze ends, the message goes out once if the alert is still firing. If the alert fires and resolves inside the snooze, nothing is sent.
- An acknowledged incident, or an alert snoozed until resolved, never sends its firing message or reminders. Its resolved message is sent only if the firing message went out.
- Ending a snooze early sends the held messages right away.
Anyone who can see the project can snooze an alert, and acknowledge, assign and comment on an alert or monitor incident. Monitor notifications are not held by an acknowledgement.
Slack and Discord channels wait 20 seconds before posting alert and uptime messages, then post one message for everything that came in, up to 25 entries. Webhooks always get one request per event.
Emails follow the same rules: each recipient gets their own delivery and retries, so one failing address never holds up the others.
Notifications are written in the same database transaction as the change that causes them, such as an alert firing, a monitor going down or a new issue being grouped. A crash or a queue outage can delay a notification by a minute, but cannot lose it. On a self-hosted instance without a running jobs worker, node ace notifications:deliver attempts every delivery that is due.
Verifying signatures
Each webhook channel has a signing secret that starts with whsec_. Admins can reveal it on the channel page and rotate it; rotation takes effect immediately, so update your receiver right away.
The signature is an HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<raw body>, keyed with the base64-decoded part of the secret after whsec_. Verify against the raw request body, before any JSON parsing:
import { createHmac, timingSafeEqual } from 'node:crypto'
const MAX_AGE_SECONDS = 5 * 60
export const isUnhandledWebhook = (
secret: string,
headers: { id: string; timestamp: string; signature: string },
rawBody: string
): boolean => {
const timestamp = Number(headers.timestamp)
const now = Math.floor(Date.now() / 1000)
if (!Number.isInteger(timestamp) || Math.abs(now - timestamp) > MAX_AGE_SECONDS) return false
const key = Buffer.from(secret.slice('whsec_'.length), 'base64')
const expected = createHmac('sha256', key)
.update(`${headers.id}.${timestamp}.${rawBody}`)
.digest()
return headers.signature.split(' ').some((candidate) => {
const [version, value] = candidate.split(',')
if (version !== 'v1' || !value) return false
const received = Buffer.from(value, 'base64')
return received.length === expected.length && timingSafeEqual(received, expected)
})
}Rejecting timestamps older than 5 minutes stops an attacker from replaying a captured request later.