Notifications Configuration
SolidPing supports multiple notification channels to alert you when incidents occur.
Supported Channels
| Channel | Status | Configuration |
|---|---|---|
| Slack | Available | OAuth integration |
| Slack (webhook) | Available | Incoming webhook URL (one-way, no app install) |
| Teams (bot) | Available | Azure Bot / Bot Framework (two-way) |
| Teams (webhook) | Available | Teams Workflow webhook (one-way) |
| Discord | Available | Bot (OAuth install, two-way) or webhook (one-way) |
| Available | SMTP | |
| Webhooks | Available | HTTP POST |
| Google Chat | Available | Webhook |
| Mattermost | Available | Webhook |
| ntfy | Available | HTTP push |
| Gotify | Available | HTTP push |
| Matrix | Available | Client-Server API |
| Zulip | Available | Bot API |
| PagerDuty | Available | Events API v2 |
| AI agent (A2A) | Available | Agent2Agent protocol |
| Pushover | Available | API integration |
| Web Push | Available | Browser push (VAPID) |
| SMS / Voice | Available | SMS & Voice (server-provided by default, per-organization Twilio as an override) |
| Available | Meta WhatsApp Business Cloud API (instance-level) | |
| Telegram | Available | Telegram Bot API (instance-level bot, free per message) |
Email (SMTP)
Configure email notifications via SMTP.
Environment Variables
SP_EMAIL_ENABLED=true
SP_EMAIL_HOST=smtp.example.com
SP_EMAIL_PORT=587
SP_EMAIL_USERNAME=noreply@example.com
SP_EMAIL_PASSWORD=your-smtp-password
SP_EMAIL_FROM=noreply@example.com
SP_EMAIL_FROMNAME=SolidPing
SP_EMAIL_AUTHTYPE=login
SP_EMAIL_PROTOCOL=starttls
SP_EMAIL_INSECURESKIPVERIFY=false
Configuration File
email:
enabled: true
host: smtp.example.com
port: 587
username: noreply@example.com
password: your-smtp-password
from: noreply@example.com
from_name: SolidPing
auth_type: login
protocol: starttls
insecure_skip_verify: false
Auth Types
| Type | Description |
|---|---|
plain | PLAIN authentication |
login | LOGIN authentication (default) |
cram-md5 | CRAM-MD5 authentication |
Protocol Options
| Protocol | Description |
|---|---|
none | No encryption |
starttls | STARTTLS (default) |
ssl | SSL/TLS |
Provider Examples
Gmail
SP_EMAIL_HOST=smtp.gmail.com
SP_EMAIL_PORT=587
SP_EMAIL_USERNAME=your-email@gmail.com
SP_EMAIL_PASSWORD=your-app-password # Use App Password, not your account password
SP_EMAIL_AUTHTYPE=login
Gmail requires an App Password when 2FA is enabled. Generate one at https://myaccount.google.com/apppasswords
SendGrid
SP_EMAIL_HOST=smtp.sendgrid.net
SP_EMAIL_PORT=587
SP_EMAIL_USERNAME=apikey
SP_EMAIL_PASSWORD=your-sendgrid-api-key
SP_EMAIL_AUTHTYPE=login
Amazon SES
SP_EMAIL_HOST=email-smtp.us-east-1.amazonaws.com
SP_EMAIL_PORT=587
SP_EMAIL_USERNAME=your-ses-smtp-username
SP_EMAIL_PASSWORD=your-ses-smtp-password
SP_EMAIL_AUTHTYPE=login
Mailgun
SP_EMAIL_HOST=smtp.mailgun.org
SP_EMAIL_PORT=587
SP_EMAIL_USERNAME=postmaster@your-domain.mailgun.org
SP_EMAIL_PASSWORD=your-mailgun-smtp-password
SP_EMAIL_AUTHTYPE=login
Slack
Slack integration uses OAuth for secure access.
Environment Variables
SP_SLACK_APP_ID=A0XXXXXXXXX
SP_SLACK_CLIENT_ID=1234567890.1234567890123
SP_SLACK_CLIENT_SECRET=your-client-secret
SP_SLACK_SIGNING_SECRET=your-signing-secret
Configuration File
slack:
app_id: A0XXXXXXXXX
client_id: "1234567890.1234567890123"
client_secret: your-client-secret
signing_secret: your-signing-secret
Signing secret
How Slack reaches SolidPing decides whether the signing secret is needed:
- HTTP delivery (the Request URLs of Event Subscriptions, Interactivity and
Slash Commands point at
/api/v1/integrations/slack/...): the signing secret is required. SolidPing checks the signature of every request and rejects every request with401whileSP_SLACK_SIGNING_SECRETis unset. The server logs a warning at startup when Slack is enabled in HTTP mode without one. - Socket Mode (
SP_SLACK_SOCKET_MODE_ENABLED=truewithSP_SLACK_APP_TOKEN): the signing secret is not needed. Slack delivers over the WebSocket opened with the app token and never calls the HTTP endpoints.
The secret is read when the server starts: after setting it from Server settings, restart the server.
Inbound Slack requests are capped at 1 MiB. A slash command follow-up is only
ever posted to a https://hooks.slack.com/ response URL.
Setting Up a Slack App
- Go to https://api.slack.com/apps
- Click "Create New App" → "From scratch"
- Name it "SolidPing" and select your workspace
- Go to "OAuth & Permissions" and add scopes:
chat:writechat:write.publicchannels:readim:history— required for direct-message capture
- Go to "Basic Information" to get your credentials
- Install the app to your workspace
The reference manifests in wiki/slack/ are the authoritative scope list; the
five above are the minimum for alerting plus DM capture.
Slash commands
| Command | What it does |
|---|---|
/check <url> | Creates an HTTP check for the URL. |
/comment [#42] <text> | Adds a comment to an incident's timeline. |
/comment is the explicit way to add a comment from Slack. A slash command
posts nothing visible in the channel, so SolidPing answers with an ephemeral
confirmation and then fans the comment out to every channel attached to the
failing check — including the incident's own Slack thread, so the channel that
typed it still sees it.
Slack's payload for a slash command does not include thread_ts, so the
command cannot tell which thread it was typed in. The incident is resolved from
the channel instead:
- An explicit
#42always wins. - Otherwise, if the channel has exactly one active incident thread, that is the target.
- Otherwise you get an ephemeral error listing the candidates — SolidPing never guesses which incident a note belongs to.
Capturing thread replies (comment_ingestion)
Each Slack integration has a "Capture every thread reply as a comment"
toggle on its edit page, stored as comment_ingestion:
| Value | Behavior |
|---|---|
explicit (default) | Only /comment creates an incident comment. Triage chatter in the thread — "lunch?", "who is on call?" — stays chatter. |
all | Every human reply in a tracked incident thread is saved to the incident timeline. |
An integration created before this setting existed carries no value and is
therefore treated as explicit, so no workspace keeps over-capturing after an
upgrade. Bot-authored messages (including SolidPing's own thread replies) are
never ingested in either mode.
Direct messages and the support inbox
A direct message to the SolidPing bot is captured in the instance
support inbox instead of being dropped. Channel
messages and @SolidPing mentions are unaffected — only channel_type: "im" is
captured.
This needs the im:history bot scope, and Slack does not grant new scopes to
an existing install. Every workspace that connected before SolidPing started
requesting it keeps its old grant, and Slack simply never delivers message.im
to us — so DMs to the bot go nowhere and the inbox looks empty rather than
broken.
Reinstalling the app is the whole fix, and it changes nothing else about the integration. SolidPing surfaces the state in three places so it does not have to be discovered from a silence:
- the integration's edit page shows a "Direct messages are not being captured" banner with a Reinstall Slack app button;
- the server logs one warning at boot naming how many workspaces still owe a reinstall;
- the
solidping_support_dm_unavailable{channel="slack"}gauge reports the same number for alerting.
Discord needs no equivalent step: its DM support is a gateway intent, not a user-granted scope.
Notification Format
Slack notifications include:
- Service name and URL
- Status change (Up → Down, Down → Up)
- Error details (for failures)
- Direct link to the check in SolidPing
Slack (webhook)
If you cannot install the Slack app (self-hosted without a Slack app, or no workspace admin to approve it), add a Slack (webhook) integration. It is a separate type from the OAuth slack integration and an organization can use both.
- In Slack, create an incoming webhook and pick the channel.
- In SolidPing, go to Integrations → New → Slack (webhook).
- Paste the URL (
https://hooks.slack.com/services/...) in Webhook URL and save.
The URL must be on hooks.slack.com. Other hosts, http:// and private addresses are rejected when you save.
Every event (created, resolved, escalated, reopened, comment, acknowledged, unacknowledged) is posted as a standalone message that carries the incident reference (#42). New organizations get slack-webhook in their default and critical severities. Existing organizations keep their current severities, so add it there yourself.
What it cannot do compared to the Slack app:
- no threads (a webhook returns no message id, so updates are not grouped under the alert)
- no Acknowledge / Escalate buttons
- no slash commands (
/check,/comment) - no capture of thread replies as incident comments
- no on-call mentions (Slack user ids are only known to the app)
Microsoft Teams
There are two Teams integrations, and they are independent — pick either, or use both:
| Integration type | What it does | What it needs |
|---|---|---|
msteams | One-way: posts Adaptive Cards into a channel | A Teams Workflow URL. No server configuration, works behind a firewall. |
msteams-bot | Two-way: alerts plus @SolidPing commands, and incident cards that update in place | An Entra ID app + Azure Bot, and a publicly reachable HTTPS endpoint. |
Teams (webhook) — the zero-infra option
- In Teams, open Workflows → "Post to a channel when a webhook request is received"
- Finish the wizard and copy the workflow URL it gives you
- Paste it into a
msteamsintegration in SolidPing
The legacy "Incoming Webhook" Office 365 connector is retired by Microsoft and will not work.
Teams (bot) — the Slack-grade option
The Bot Framework has no Socket-Mode equivalent: Microsoft's servers push activities to your instance, they never dial out from it. Your SolidPing instance must therefore be reachable from the public internet over HTTPS at:
https://<your-base-url>/api/v1/integrations/msteams/messages
A self-hosted instance behind a firewall or on a private network cannot use
the Teams bot. Use the msteams webhook integration instead — it only makes
outbound requests.
1. Register the Entra app and Azure Bot
- In the Azure portal, create an Azure Bot resource (multi-tenant for a shared deployment, single-tenant for your own).
- Note the Microsoft App ID and create a client secret.
- Set the bot's messaging endpoint to
https://<your-base-url>/api/v1/integrations/msteams/messages. - Enable the Microsoft Teams channel on the bot.
2. Configure SolidPing
SP_MSTEAMS_ENABLED=true
SP_MSTEAMS_APP_ID=00000000-0000-0000-0000-000000000000
SP_MSTEAMS_APP_SECRET=your-client-secret
# Optional: restrict inbound activities to a single Microsoft 365 tenant.
SP_MSTEAMS_TENANT_ID=11111111-1111-1111-1111-111111111111
msteams:
enabled: true
app_id: 00000000-0000-0000-0000-000000000000
app_secret: your-client-secret
tenant_id: "" # empty = multi-tenant
msteams.enabled defaults to false — the bot stays off until you turn it
on, precisely because of the public-endpoint requirement above.
3. Install the app in Teams and link your tenant
-
In SolidPing, create a Teams (bot) integration and open it.
-
Click Download Teams app package — the zip is generated with your instance's app ID and URL already filled in, so nothing has to be edited.
-
In Teams: Apps → Manage your apps → Upload a custom app, pick the zip, and add SolidPing to a team.
-
Back in SolidPing, click Connect Microsoft Teams. You get a one-time link code.
-
In a Teams channel the bot was added to, send:
@SolidPing link ABCDE-FGHIJThe bot confirms in the channel, and the integration is connected.
Every channel you add the bot to becomes a selectable notification destination; the first one becomes the default.
A Microsoft 365 tenant ID is a semi-public identifier, not a secret, so simply typing one into a form proves nothing — anyone could claim any tenant, and then receive that tenant's channel names and post into its channels. Bot Framework has no OAuth redirect that could carry your SolidPing organization through the install the way Slack's does, so the link code fills that gap: SolidPing issues it to a signed-in admin of one organization, and it can only be redeemed from inside a real, Microsoft-signed message. Quoting the code back is what proves both sides are the same actor.
The code is single-use and expires after 30 minutes. A tenant can be linked to exactly one SolidPing organization. The command must be run in a team channel, not a private chat, so the link is visible to your team.
The link code proves that whoever redeems it holds a code issued to a signed-in SolidPing admin — it does not prove that the person redeeming it is an owner of the Teams team. Microsoft's Bot Connector does not tell a bot the sender's role, and determining it would require Microsoft Graph permissions and admin consent that this integration deliberately does not request. In practice that means any member of a team the bot has been added to could redeem a leaked code.
Treat a link code like a password: generate it when you are ready to use it, paste it once, and let it expire otherwise.
If a tenant ends up linked to the wrong organization, the tenant's own admin can recover without SolidPing support: remove the SolidPing app from the tenant in Teams, then reinstall it and link again with a code from the correct organization. Uninstalling releases the tenant's claim — it is the one lever the party who actually controls the tenant always holds. (An admin of the holding organization can also simply delete the integration.)
Publishing to the Teams store is out of scope — custom app upload ("sideloading") is the supported path, which your Teams admin may need to allow in the Teams admin center.
What the bot can do
- Posts an Adaptive Card when an incident opens, updates that same card when the incident escalates or resolves, and replies under it so a channel shows one incident as one grouped conversation.
- Answers
@SolidPingcommands in a channel:@SolidPing help@SolidPing checks add <url>/checks list/checks rm <slug>@SolidPing results -check <slug>@SolidPing incidents list [-check <slug>]@SolidPing config default-channel— makes the current channel the default notification target (Teams has no cross-team channel reference, so the command is scoped to the team it is issued in)@SolidPing link <code>— connects this Microsoft 365 tenant to a SolidPing organization (see step 5 above)
- Stops routing when the app is removed from the tenant, and resumes on reinstall without losing the org's notification wiring.
Personal-scope direct messages are not supported yet.
Security
Every inbound activity is authenticated before anything is acted on. The Bot
Connector JWT is verified against Microsoft's published JWKS, and the issuer,
audience (your app ID), signing algorithm, validity window and serviceurl
binding are all checked — the serviceurl check is mandatory, so a captured
token cannot be replayed with a different body to redirect SolidPing's outbound
calls.
Microsoft's Connector-to-Bot token carries no tenant claim, so tenant identity
comes from the activity body — which is trustworthy precisely because the
request carrying it passed that signature check. Ownership of a tenant is a
separate question, and is what the link code establishes. When
msteams.tenant_id is set, activities from any other tenant are rejected.
Notification destinations are held to the same standard. A Teams conversation ID is discoverable (it appears in "Get link to channel" URLs), so SolidPing only accepts a destination the bot has actually been added to — enforced when the channel is picked in the dashboard, when a per-check override is set, and again at send time. Naming a conversation ID you happen to know is not enough to make SolidPing post there.
Known limitations
- Managed-identity bots are not supported. Outbound tokens are minted with
a client secret, against the multi-tenant endpoint by default or your own
tenant's endpoint when
msteams.tenant_idis set. A bot registered with a user-assigned managed identity takes its token from the instance metadata endpoint instead, which is a different credential model. - Teams store publication is out of scope — custom app upload only.
- The link command cannot verify the sender is a team owner — see the caution above for the reasoning and the recovery path.
Discord
Discord comes in two flavours, and an organization may use either.
| Bot | Webhook | |
|---|---|---|
| Setup | Install the SolidPing bot into your server | Paste a webhook URL |
| Alerts | Rich embed | Rich embed |
| One thread per incident | Yes | No — every event is a new post |
| Resolve edits the original message | Yes | No |
| Acknowledge button | Yes | No |
| On-call @-mentions | Yes | No |
| Direct message to one person | Yes | No |
| Slash + mention commands | Yes | No |
| Thread replies → incident comments | Yes (needs the Gateway) | No |
| Needs instance-level configuration | Yes | No |
The bot is the recommended mode when the instance has one. The webhook mode needs no Discord application, so it is what an instance without a Discord bot uses, and an integration created before the bot existed keeps working with no migration.
Which mode an integration uses is decided at send time:
- a bot destination (server and channel, or a direct message) is set: the bot;
- otherwise a webhook URL is set: the webhook;
- otherwise the test and every alert fail with "This Discord integration has no destination. Install the bot or add a webhook URL."
Setting up the bot (organization admins)
- Open the Discord integration in SolidPing and press Install Discord bot.
- Approve the install for your server in Discord.
- Back in SolidPing, pick the channel alerts should go to.
- Optionally turn on Mention the on-call person and Capture every thread reply as a comment.
SolidPing requests only the permissions it uses: View Channel, Send Messages, Embed Links, Read Message History, Create Public Threads, Send Messages in Threads and Manage Threads. Manage Threads is what lets SolidPing re-open a thread Discord auto-archived, so a long incident's "resolved" notice still lands in the incident's own thread instead of vanishing.
Direct messages to one person
Discord can page an individual, not just a channel. There are two separate things with the same name, and they are configured in different places:
| Personal DM contact | DM destination | |
|---|---|---|
| Who sets it up | The member, under Account → Notifications | An org admin, on the Discord integration |
| What it is | A notification route the escalation policy pages | A channel destination that happens to be a DM |
| What arrives | Only the incidents that page you | Every alert the integration sends |
| Needs an org Discord integration | No | Yes |
Connecting your own Discord (members)
Under Account → Notifications, the Discord row offers one of two things:
- Connect Discord — you have signed in with Discord before, so SolidPing already knows your account and binds it in one click;
- Link Discord — you have not, so it sends you through Discord once and brings you back.
There is deliberately no field to type a Discord user id into, and the API rejects one. A Discord user id is public — anyone can copy a stranger's out of a Discord client in two clicks — so accepting a typed one would let any user point our incident DMs at somebody else's account, indefinitely, with nothing telling that person where the messages came from. Both connect paths are bindings Discord itself attested, which is why neither needs a verification code.
A DM contact goes through the instance bot, the way Telegram does, so it needs no Discord integration in your organization. It does need the instance to have a configured bot — the row is simply absent otherwise.
Pages that arrive as a DM carry the same Acknowledge button a channel alert does, and pressing it acknowledges the incident exactly as it would in a channel. Only someone whose Discord account is connected to a member of that incident's organization can do so.
Routing a check's alerts to one person (admins)
On a bot-installed Discord integration the destination picker has Channel and
Direct message tabs. The DM tab lists the organization's members whose
Discord account SolidPing can already resolve — an admin mapping, a personal
Discord contact, or a Discord sign-in. It is not the server's member list:
reading that needs the privileged GUILD_MEMBERS intent, and it would tell us
who is in the server without telling us which SolidPing account any of them is.
A DM destination behaves like a channel one with two differences, both forced by Discord: a DM cannot host a thread, so follow-ups post as plain messages that link back to the original instead of threading under it; and the on-call mention is skipped, because a DM already has exactly one reader.
When Discord refuses a DM
Discord will not let a bot DM someone who has direct messages from server members switched off, who has blocked it, or who shares no server with it. That is not something an operator can configure around — it is the recipient's own privacy setting.
Use the Test button to find out. On Account → Notifications it reports the refusal in as many words ("open your DMs for server members, or join the server the bot is in") rather than a generic failure, and the DM tab reports it at pick time rather than during your first real incident.
When a refusal happens during real paging, SolidPing treats it as this route cannot be used and falls through to the member's next notification route. It is not counted as a delivery and not reported as an outage.
Instance-level configuration (operators)
The bot is a single Discord application per SolidPing instance. Configure it under Server → Discord:
| Setting | Env var | Purpose |
|---|---|---|
| Enabled | SP_DISCORD_ENABLED | Master switch: nothing Discord (login routes, bot) is mounted without it |
| Client ID | SP_DISCORD_CLIENT_ID | Identifies the application during install |
| Client secret | SP_DISCORD_CLIENT_SECRET | Completes the install token exchange |
| Bot token | SP_DISCORD_BOT_TOKEN | Authenticates every outbound call |
| Public key | SP_DISCORD_PUBLIC_KEY | Verifies Discord's signed interaction requests |
| Gateway enabled | SP_DISCORD_GATEWAY_ENABLED | Turns on the inbound WebSocket |
Discord application setup
Create the application at the Discord Developer Portal,
then fill in these pages in the order the portal shows them. Server → Discord
and Server → Authentication in the dashboard show the exact URLs below for
your instance, each with a copy button. {SP_BASE_URL} is your instance's public
address.
- OAuth2 → Redirects: add both URIs. Login and the bot install are two
different flows on the same application, and each one needs its own entry.
Registering only one makes the other fail with Discord's "Invalid OAuth2
redirect_uri".
{SP_BASE_URL}/api/v1/auth/discord/callback(Sign in with Discord){SP_BASE_URL}/api/v1/integrations/discord/oauth(Install Discord bot)
- Installation: scopes
bot,applications.commandsandidentify. Bot permissions: View Channels, Send Messages, Embed Links, Read Message History, Manage Threads, Create Public Threads, Send Messages in Threads. - General Information → Interactions Endpoint URL:
{SP_BASE_URL}/api/v1/integrations/discord/interactions. Save it afterSP_DISCORD_PUBLIC_KEYis set: Discord probes the URL on save and rejects it if SolidPing cannot verify signatures yet. This step is optional when the Gateway is on: with no endpoint URL, Discord delivers button presses and slash commands over the Gateway instead, which needs no inbound access. - Bot → Privileged Gateway Intents: enable Message Content Intent if you turn the Gateway on.
If SP_BASE_URL is still http://localhost:4000, Discord redirects the browser
to localhost. Set it to the address users reach SolidPing on. The dashboard
warns about this next to the URLs.
SP_DISCORD_REDIRECT_URL no longer exists. Both URIs derive from SP_BASE_URL,
and setting the variable only logs a warning at startup.
Three things are worth understanding before you turn the bot on:
-
The bot needs all four values, and it fails closed without them. Discord login runs on the client id and secret alone, so it is entirely possible to have sign-in working and no bot at all. When the bot token or the public key is missing, SolidPing does not mount the install routes and the dashboard does not offer the install button — the channel is simply not offered, rather than dead-ending at Discord. One warning naming the missing variables is logged at boot.
-
The public key is not optional. Discord probes the interactions endpoint with deliberately invalid signatures and deactivates it if those probes are not rejected. With no public key configured, SolidPing rejects every interaction rather than trusting it — so buttons and slash commands do nothing until the key is set.
-
The Gateway needs the privileged
MESSAGE_CONTENTintent. Buttons and slash commands arrive over HTTPS when an Interactions Endpoint URL is set, and over the Gateway when it is not. Discord has no HTTP event subscription for ordinary messages — everything a human types (a thread reply that should become an incident comment, an@SolidPing checks list) only arrives over the Gateway. Enable the intent under Bot → Privileged Gateway Intents in the Discord Developer Portal. Discord grants it freely while your application is in fewer than 100 servers and requires a review above that, so request it early if you expect to grow. Without the intent the bot connects and looks healthy, but every message arrives with empty content and it silently ignores everything.
Full operator setup notes live in wiki/discord/README.md in the SolidPing
repository.
Commands
Available as slash commands (/solidping …) and as bot mentions
(@SolidPing …):
| Command | What it does |
|---|---|
checks list | List monitored checks |
checks add <url> | Start monitoring a URL |
checks rm <slug> | Stop monitoring a check |
incidents list [check] | Recent incidents |
comment [#N] <text> | Add a note to an incident — inside an incident thread the number is optional |
config default-channel #channel | Change where alerts go |
help | Command reference |
Setting up a webhook instead
Use a webhook when this instance has no Discord bot configured (no
SP_DISCORD_BOT_TOKEN), or when you only want one-way alerts in a channel.
- In your Discord server, go to Server Settings → Integrations
- Click "Webhooks" → "New Webhook"
- Name it "SolidPing" and select the channel
- Copy the Webhook URL
- Add the webhook URL in SolidPing's integration settings
Webhook URL format:
https://discord.com/api/webhooks/{webhook.id}/{webhook.token}
Only Discord webhook URLs are accepted on save: https://discord.com/api/webhooks/…
or https://discordapp.com/api/webhooks/… (and their canary. / ptb.
variants). Anything else is rejected with a validation error.
Google Chat
Google Chat notifications use incoming webhooks.
Setting Up Google Chat Webhooks
- In Google Chat, open the space where you want notifications
- Click the space name → "Apps & integrations" → "Manage webhooks"
- Click "Add webhook", name it "SolidPing", and copy the URL
- Add the webhook URL in SolidPing's integration settings
Mattermost
Mattermost notifications use incoming webhooks, similar to Slack.
Setting Up Mattermost Webhooks
- In Mattermost, go to Main Menu → Integrations → Incoming Webhooks
- Click "Add Incoming Webhook"
- Select the channel and fill in the details
- Copy the webhook URL
- Add the webhook URL in SolidPing's integration settings
ntfy
ntfy is a simple HTTP-based pub/sub notification service. SolidPing can push notifications to any ntfy topic.
Configuration
Add a ntfy connection in SolidPing with:
- Server URL (setting key
server_url):https://ntfy.sh(or your self-hosted instance) - Topic (
topic): Your topic name (e.g.,solidping-alerts) - Priority (
priority, optional): 1 to 5. Replaces the default priority of every alert except recoveries, which stay at the default priority. - Access token (
auth_token, optional, API only): sent as a bearer token, stored encrypted.
Notifications carry a click-through link to the incident (ntfy's Click field) when
SolidPing's base URL is configured. A program reading the topic gets the incident uid from it.
Example
# Subscribe to notifications
ntfy subscribe solidping-alerts
Gotify
Gotify is a simple self-hosted push notification server, popular alongside ntfy in the homelab/self-hosted crowd. SolidPing posts alerts to a Gotify application via its HTTP API.
Configuration
- In your Gotify web UI, go to Apps and create a new application for SolidPing. Copy the generated application token.
- Add a Gotify connection in SolidPing with:
- Server URL: your Gotify instance's base URL (e.g.
https://gotify.example.com) — Gotify is always self-hosted, there's no default. - Application token: the token from step 1. Treated as a secret — stored encrypted, never shown again after you save it.
- Priority (optional): the default Gotify priority (0-10) for alerts, default
5. A resolved incident always sends at a low priority (2) so a recovery doesn't re-buzz your phone the way a page does.
- Server URL: your Gotify instance's base URL (e.g.
Notifications carry a click-through link to the incident when SolidPing's base URL is configured, so tapping the push notification opens the incident directly.
Matrix
Matrix is an open, decentralized chat protocol — the standard for self-hosted chat (Element, Synapse, Conduit, beeper). SolidPing sends notifications as a dedicated bot user posting into a room, via the Matrix Client-Server API — no bridge or generic webhook needed.
Configuration
Add a Matrix connection in SolidPing with:
- Homeserver URL: the base URL of the bot account's homeserver (e.g.
https://matrix.org, or your self-hosted Synapse/Conduit instance). A trailing slash is fine. - Access token: the bot/dedicated user's access token. Treated as a secret — stored encrypted, never shown again after you save it.
- Room: the room ID (
!abcdef:matrix.org) or alias (#alerts:matrix.org) to post into. The bot account must already be invited to and have joined the room — SolidPing does not auto-join.
Creating a bot user and access token
- Register a dedicated account for SolidPing on your homeserver (don't reuse a personal account — the access token can post as that user indefinitely).
- Get an access token for that account, either:
- In Element: sign in as the bot user, then go to Settings → Help & About → Advanced → Access Token, or
- Via the API:
POST /_matrix/client/v3/loginwith the bot's credentials, which returns anaccess_tokenin the response.
- Find the room ID: in Element, open the room, go to Room settings → Advanced, and copy
the internal room ID (starts with
!). A room alias (starts with#) also works — SolidPing resolves it to the current room ID on every send. - Invite the bot account to the room and accept the invite as the bot (join it) — SolidPing only posts into rooms the bot is already a member of.
Limitations
- End-to-end encrypted rooms are not supported (posting into one would require a full Matrix crypto SDK). Use an unencrypted room for alerts.
- SolidPing does not auto-join a room on invite — join it manually as the bot account first.
Zulip
Zulip is an open-source chat tool whose threading model — every message lives in a named topic inside a stream — maps naturally onto "one thread per incident". SolidPing posts through Zulip's bot API, using the same topic string for every lifecycle event of an incident (created, escalated, comments, acknowledgment, resolved) so Zulip threads the whole incident together automatically, without any extra configuration.
Configuration
Add a Zulip connection in SolidPing with:
- Site URL: your Zulip realm's base URL (e.g.
https://acme.zulipchat.comfor Zulip Cloud, or your self-hosted realm's URL). - Bot email: the bot's email identity (e.g.
solidping-bot@acme.zulipchat.com). - API key: the bot's API key. Treated as a secret — stored encrypted, never shown again after you save it.
- Stream: the name of the stream (channel) to post into. The bot must already be subscribed to it — SolidPing does not auto-subscribe.
Creating a bot and finding its API key
- In your Zulip organization, go to Settings → Personal → Bots (or, for an organization-wide bot, Organization settings → Bots) and add a new bot — a Generic bot is the right type for posting alerts.
- Zulip shows the bot's email and API key on its bot detail page immediately after creation. If you need the key again later, open the bot's row in Bots and click the key icon to reveal it.
- Subscribe the bot to the stream you want SolidPing to post into — as an organization admin, add the bot's email as a subscriber from Organization settings → Channels (streams), or invite it into the stream the same way you'd invite any other user.
Topic per incident
Each incident's Zulip topic is derived deterministically from the check name and the
incident's short reference, e.g. API health (#42), truncated to Zulip's 60-character
topic limit when needed. Every event for that incident reuses the same string, so the
whole lifecycle — the initial alert, any escalation, comments, an acknowledgment, and the
eventual resolution — reads as one continuous thread in Zulip instead of separate,
unrelated messages.
When a check name is long enough that the full string would exceed 60 characters, the
check name is shortened, never the (#42) reference — that reference is the only part of
the topic that tells two incidents on the same check apart, so it always survives intact.
PagerDuty
PagerDuty integration for incident management and on-call alerting, via the Events API v2 only — no OAuth, no REST API v2, no schedule import.
Configuration
Add a PagerDuty connection in SolidPing with:
- Integration key: The routing key of a PagerDuty Events API v2 integration
Setting Up PagerDuty
- In PagerDuty, open the service that should receive SolidPing's alerts
- Go to Integrations → Add integration
- Choose Events API v2
- Copy the generated integration key
- Add it in SolidPing's integration settings
Behavior
- An incident opening (or reopening) sends a
triggerevent; resolving it sends aresolveevent. Both carry the incident's UID as thededup_key, so they correlate to one PagerDuty incident across its whole lifecycle. - A comment or an escalation sends nothing to PagerDuty: the Events API
v2 has no note/annotation concept, and reusing the
dedup_keyon atriggerwould re-open an already-resolved incident. - Severity (
critical/error/warning/info) is derived from the result that triggered or is currently failing the incident.
Pushover
Pushover delivers real-time notifications to your mobile devices and desktop.
Configuration
Add a Pushover connection in SolidPing with:
- User Key (setting key
user_key): Your Pushover user key - API Token (setting key
api_token): Your Pushover application API token
Both are stored encrypted. When you create the integration through the API or
apply, use these two keys:
{ "type": "pushover", "name": "Phone", "settings": { "user_key": "u...", "api_token": "a..." } }
Integrations saved before v0.39 with the keys user/token or userKey/apiToken
are rewritten to user_key/api_token at startup.
If Send test says the integration is missing a setting, nothing was sent: fill in that field and save before testing again.
Setting Up Pushover
- Create an account at https://pushover.net
- Create a new Application/API Token for SolidPing
- Add your user key and API token in SolidPing's integration settings
Web Push
Web Push delivers browser notifications straight to a user's device — no third-party service required. It is typically used as an on-call/escalation contact so an operator gets paged even when the dashboard tab is closed.
Web Push relies on VAPID keys that the server holds. Once configured, each user opts in from their notification settings, and the browser registers a push subscription.
Environment Variables
SP_WEBPUSH_ENABLED=true
SP_WEBPUSH_VAPID_PUBLIC_KEY=your-vapid-public-key
SP_WEBPUSH_VAPID_PRIVATE_KEY=your-vapid-private-key
SP_WEBPUSH_SUBJECT=mailto:admin@example.com # contact URI advertised to push services
Configuration File
webpush:
enabled: true
vapid_public_key: your-vapid-public-key
vapid_private_key: your-vapid-private-key
subject: mailto:admin@example.com
Generate a VAPID key pair with any web-push tooling (e.g. npx web-push generate-vapid-keys).
Keep the private key secret.
Webhooks
Generic webhooks send an HTTP POST with a JSON body to any URL. Every
request is signed per the Standard Webhooks
specification, so receivers can verify it came from SolidPing.
Payload Format
{
"type": "incident.resolved",
"timestamp": "2026-01-15T10:42:00Z",
"data": {
"incident": {
"uid": "550e8400-e29b-41d4-a716-446655440001",
"kind": "check",
"startedAt": "2026-01-15T10:30:00Z",
"resolvedAt": "2026-01-15T10:42:00Z",
"durationSeconds": 720,
"title": "api-health is down",
"failureCount": 3,
"relapseCount": 0,
"recoveryPeriodSeconds": null,
"resolutionType": "auto",
"regions": {
"failingRegions": ["eu-west", "us-east"],
"regionCount": 3,
"failQuorum": 2
}
},
"check": {
"uid": "550e8400-e29b-41d4-a716-446655440000",
"name": "API Health Check",
"type": "http"
}
}
}
kindsays what the incident is about:check(the check is down),degraded(an intermittence pattern on a check that is usually up) orslo_burn(an SLO error-budget burn alert). All three use the same event types, so readkindbefore treating anincident.createdas an outage.resolvedAtanddurationSecondsarenullwhile the incident is active.resolutionType(auto,manual,expired,escalated,disabled,check_deleted,check_disabled) is only present once the incident is resolved.acknowledgedAtis only present once the incident has been acknowledged.recoveryPeriodSecondsisnulluntil the incident has relapsed at least once.regionsholds the slugs (not labels) of the regions that saw the failure, how many regions the check runs from and the quorum that makes it down. It is omitted when no region is known, and a single-region check gives{"failingRegions": ["eu-west"], "regionCount": 1, "failQuorum": 1}. It is a snapshot taken when the incident opens (or reopens) and refreshed on escalation, not the live state.incident.commentevents adddata.comment({"text", "authorName", "source"}).incident.acknowledgedandincident.unacknowledgedevents adddata.acknowledgment({"actorName", "via"}).incident.reminderevents adddata.incident.reminderCount, the number of this reminder (1for the first). It is absent on every other event.
Event Types
| Event | Description |
|---|---|
incident.created | New incident (check started failing) |
incident.escalated | Incident reached an escalation step |
incident.reopened | A resolved incident started failing again |
incident.resolved | Incident resolved (check recovered) |
incident.acknowledged | Someone took the incident |
incident.unacknowledged | The acknowledgment was withdrawn |
incident.reminder | Still open and unacknowledged after the reminder interval (off by default, see Reminders) |
incident.comment | A comment was added to the incident |
Webhook Configuration
In the SolidPing dashboard:
- Go to Integrations and add a Webhook integration
- Set the Webhook URL to your endpoint and save
- Open the integration again: the Signing secret is shown there, with Copy and Rotate buttons
Custom request headers (for example Authorization) can be set through the
API in the integration's settings.headers object. They cannot override the
three signing headers.
A response with a status code of 300 or more, or no response within 30 seconds, counts as a failed delivery.
Signature Verification
Each request carries three headers:
| Header | Content |
|---|---|
Webhook-Id | Unique ID of this delivery (a UUID). Use it to deduplicate. |
Webhook-Timestamp | Unix timestamp, in seconds, of the delivery |
Webhook-Signature | Space-separated list of v1,<signature> entries |
The signature is computed as follows:
- The signed content is
{Webhook-Id}.{Webhook-Timestamp}.{raw body}, joined with full stops. Use the raw request body exactly as received, not a re-serialized copy. - The key is the signing secret with its
whsec_prefix removed, then base64-decoded. SolidPing generates 32 random bytes encoded as standard base64 with padding (the secret ends with a single=). - The signature is the HMAC-SHA256 of the signed content with that key,
encoded as standard base64, prefixed with
v1,.
Accept the request if any entry of Webhook-Signature matches. Compare in
constant time, and reject requests whose timestamp is too far from your clock
(the Standard Webhooks libraries allow 5 minutes) to block replays.
import base64
import hashlib
import hmac
import time
def decode_secret(secret: str) -> bytes:
key = secret.removeprefix("whsec_")
# Older secrets were URL-safe base64 without padding; the dashboard now
# shows the same key in standard form. Accept both spellings.
key = key.replace("-", "+").replace("_", "/")
return base64.b64decode(key + "=" * (-len(key) % 4))
def verify_webhook(body: bytes, headers: dict, secret: str, tolerance: int = 300) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > tolerance:
return False
signed = f"{msg_id}.{timestamp}.".encode() + body
digest = hmac.new(decode_secret(secret), signed, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
for entry in headers["webhook-signature"].split():
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False
The official Standard Webhooks libraries
(standardwebhooks on PyPI and npm, plus Go, Rust, Java, Ruby, PHP, C# and
Elixir) verify these requests. Pass them the secret exactly as the dashboard
shows it, whsec_ prefix included. No conversion is needed.
Rotating the secret
Rotate (or POST /api/v1/orgs/{org}/integrations/{uid}/rotate-secret)
generates a new secret. The previous one stays valid for 24 hours: during that
window each request carries two v1, signatures, one per secret, so receivers
can switch over without dropping deliveries. Rotating again drops the older
secret right away: the current one becomes the previous one.
A secret copied before SolidPing switched to standard base64 is the same key in another spelling (URL-safe, no padding). It keeps working, no rotation needed.
Incident Thresholds
Each check sets when its notifications are sent:
| Setting | Default | Description |
|---|---|---|
confirmationPeriodSeconds | 120 | Seconds the check must keep failing before the incident opens and incident.created is sent. 0 sends it on the first failure |
recoveryPeriodSeconds | 120 | Seconds the check must keep passing before the incident resolves and incident.resolved is sent. 0 sends it on the first success |
escalationThreshold | 10 | Failing results an incident counts before incident.escalated is sent (once). Not editable |
Both periods accept 0 to 86400 seconds. In the dashboard they are
Confirmation Period and Recovery Period under Incident tracking. In
config as code they are confirmationPeriod and recoveryPeriod, written as
durations (2m). Checks with several regions, flap backoff and a worked
timeline: see Incidents.
Example
Notify after 5 minutes of failures, resolve after 10 minutes of passing results:
curl -X PATCH http://localhost:4000/api/v1/orgs/default/checks/{uid} \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"confirmationPeriodSeconds": 300, "recoveryPeriodSeconds": 600}'
For rotation schedules and multi-step paging (page the next person if nobody acknowledges), see On-Call & Escalation.
Testing Notifications
Test your notification setup:
- Create a check for a known-failing endpoint
- Wait for the incident to trigger
- Verify notifications are received
- Fix the endpoint and verify resolution notification
Or use the API:
# Send test notification (requires auth)
curl -X POST http://localhost:4000/api/v1/orgs/default/integrations/{uid}/test \
-H "Authorization: Bearer $TOKEN"