docs

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

EventInboxEmailChannels
New issueAll project membersProject members with Exception summaries on, batchedPer the project's exception rules, batched
Regressed issuenoProject members with Exception summaries on, batchedPer the project's exception rules, batched
Issue assigned to youyesyesno
Status change on an issue you are assigned toyesWith Issue status changes onno
Comment on an issue you are assigned toyesWith Issue comments onno
Monitor down or recoveredAll project membersProject members with Uptime alerts onChannels with Uptime monitors as a default, or the channels picked on the monitor
TLS certificate expiring (14, 7 and 1 days before)All project membersProject members with Uptime alerts onno
Alert firing or resolvedProject members, if the alert notifies App membersSame, with Endpoint alerts onOnly the channels selected on the alert
Weekly recapnoProject members with Weekly recaps onno

"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:

StatusMeaning
PendingWaiting for its first attempt, its next retry, or the end of an alert snooze.
DeliveredThe channel answered with a 2xx.
FailedEvery retry failed, or the channel rejected it for good (for example a 404). Failed rows are kept so you can see what was lost.
GroupedSent together with other notifications as one message, shown by its number.
SupersededNot 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:

  1. Service and environment.
  2. Service only.
  3. Environment only.
  4. 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.

ChannelsEmail
Collection window60 seconds per channel60 seconds per person
Issues listedup to 10, plus the totalup to 30
Minimum gapnone5 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:

json
{
  "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

Typedata fields
uptime.downincidentId, dedupKey, monitorId, monitorName, monitorUrl, environmentName, applicationName, organizationSlug, url, errorType (timeout, dns, connection, tls, http, ssrf, keyword, heartbeat or unknown), downAt
uptime.recoveredThe monitor fields, recoveredAt, and downAt (null when unknown)
alert.firingincidentId, dedupKey, ruleId, ruleName, metric, comparator, threshold, windowMinutes, method, route, serviceName, applicationName, environmentName, organizationSlug, alertUrl, endpointUrl, slowestTracesUrl, startedAt, value, samples
alert.reminderThe same rule fields, value (the latest) and peakValue
alert.resolvedThe same rule fields without value and samples, plus resolvedAt, peakValue and reason (recovered, no_data, rule_edited, rule_paused or rule_deleted)
alert.errorruleId, ruleName, applicationName, environmentName, organizationSlug, alertUrl, failures, failedAt
exception.issuestotal, organizationName, and issues: up to 10 entries with title, application, environment, firstAppearance, regression, url
testorganizationName

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:

HeaderValue
content-typeapplication/json
user-agentUnhandled-Webhooks/1.0
webhook-idA unique message ID. Retries reuse it, so you can deduplicate.
webhook-timestampUnix time in seconds.
webhook-signaturev1, 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:

EventsAttemptsWaits between attempts
Uptime and alerts710 s, 30 s, 2 min, 5 min, 15 min, 30 min
exception.issues630 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:

ts
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.