Integrations
Tell something other than your phone. Every integration keeps a per-event delivery log, so when Slack goes quiet you can see whether it was you or them.
| Kind | Needs | Notes |
|---|---|---|
| webhook | url. Optional secret; one is minted and shown once if you leave it out. | Signed. Payload below. |
| slack | webhook_url from an incoming-webhook app. | |
| telegram | token for a bot and a chat_id. | |
| discord | webhook_url from a Discord channel webhook. | Mentions off, markdown escaped. See Discord. |
| apns | Nothing. Rejected if you send credentials. | Push to the iOS app. Recipients are the tenant’s members; the row exists so you can filter its events and read its delivery log. |
Credentials, and a webhook’s signing secret, are sealed at rest and never returned by a read. Set -secret-key on a self-hosted daemon before creating any. A channel, webhook or action saved before Pingtower recorded who last pointed it at its destination reads back with "pointer_review": true, and the web app shows it with a review pill. Check the destination is yours. The flag clears when a member whose role holds members:write (an owner, an admin, or a custom manager role) saves it, when its destination changes, or when it is re-enabled.
A project holds at most 50 webhooks and integrations together.
Create one
POST /v1/projects/{id}/integrations
{
"name": "ops-slack",
"type": "slack",
"webhook_url": "https://hooks.slack.com/services/…",
"events": ["alert.opened", "alert.escalated", "alert.resolved"]
}
The credential fields from the table sit at the top level of the body, beside name and type. events narrows which alert events it receives: alert.opened, alert.bumped, alert.escalated, alert.acked, alert.resolved. Leave it out to receive all five. The mute notices rule.muted and rule.unmuted can be listed too. A notice reaches a channel whose events is empty, names that notice, or names any alert.* event, so a channel filtered to alert.opened still hears that paging went quiet. In the app: Integrations → New.
POST /v1/projects/{id}/integrations/{iid}/test sends a synthetic alert.test event synchronously and returns what the far end said, so you can see a 403 from Slack before you save. A disabled integration answers 409. An apns test arrives as an ordinary notification, never a critical one.
On a self-hosted box, the pingtower.com relay row belongs to PUT /v1/relay. The edit, enable, disable and delete routes answer 409 for it, testing it needs owner, admin or any role with team_settings:write and is rate limited, and no other integration can take its name (422).
Discord
Create one with "type": "discord" and the URL of a Discord channel webhook.
{
"name": "oncall-discord",
"type": "discord",
"webhook_url": "https://discord.com/api/webhooks/…/…"
}
The URL must be https on discord.com, discordapp.com, ptb.discord.com or canary.discord.com, with a path under /api/webhooks/. Anything else is refused, so a Discord integration cannot be pointed at another host.
Each alert event is posted as one message, in the same text Slack and Telegram get. That text carries what your applications logged, so:
- Mentions are off. The message parses no mentions, so
@everyone,@hereor a user or role mention in a log line pings nobody. - Markdown is escaped. Every character Discord reads as formatting is escaped:
* _ ~ ` | > # - [ ] ( ) <and the backslash. A log line cannot render as a heading, a quote or a masked link that looks like it came from Pingtower. - Links don’t unfurl. Embeds are suppressed, so a URL in a log line shows as text with no preview.
- Long messages are cut. Discord refuses a message over 2,000 characters, so a longer one is cut to fit and ends in
….
Delivery goes through the same outbox, retries and failure streak as Slack. A slow answer from Discord does not count against the integration, since the host is Discord’s, shared by everyone.
Webhook payload
{
"event": "alert.opened",
"alert_id": "…",
"project": "checkout",
"rule": "gateway-timeouts",
"template_sha256": "…",
"state": "firing",
"level": 400,
"count": 7,
"escalation_level": 0,
"message": "payment gateway timeout after 30012ms for order o_48113",
"at": 1756696364,
"identity": "gateway-timeouts",
"identifiers": { "gateway": "stripe" },
"page_accounts": ["…"]
}
identifiers and page_accounts are present only when the rule set them.
Mute notices
When someone mutes or unmutes a rule, the project’s enabled webhooks and chat integrations get a notice, even though the rule’s own mute holds back its pages. Push devices and the relay row don’t. The payload has the same shape, with alert_id empty, kind set to rule, and two extra fields:
{
"event": "rule.muted",
"alert_id": "",
"project": "checkout",
"rule": "gateway-timeouts",
"template_sha256": "",
"state": "",
"level": 0,
"count": 0,
"escalation_level": 0,
"message": "gateway-timeouts muted by [email protected] until 2026-09-30T15:00:00Z",
"at": 1790776800,
"identity": "",
"kind": "rule",
"muted_by": "[email protected]",
"muted_until": 1790780400
}
muted_by is the account’s email, or API key when a key with no account did it. muted_until is present on rule.muted only. Slack, Telegram and Discord show it as [rule.muted] checkout / gateway-timeouts: muted by [email protected] until ….
Maintenance notices
Creating, changing or deleting a maintenance window, through the API, the web app or an AI assistant, sends maintenance.scheduled, maintenance.changed or maintenance.cancelled to the same integrations a mute notice goes to: every enabled one whose events is empty, names that event, or names any alert.* event. Push devices and the relay row don’t get it. The payload has the alert event’s shape with alert_id empty, kind set to maintenance, and window, by, starts_at, ends_at, timezone and, for a recurring window, rrule. The message, which Slack, Telegram and Discord show, reads:
maintenance window "Quiet hours" scheduled by [email protected]: paging held from 2026-10-04T22:00 (Europe/Berlin) until 2026-10-05T02:00, repeating FREQ=WEEKLY;BYDAY=SA
maintenance window "Quiet hours" cancelled by [email protected]; paging resumes
changed reads like scheduled, with changed by.
Verifying the signature
Two headers are sent: X-Pingtower-Timestamp, a unix timestamp, and X-Pingtower-Signature, the hex HMAC-SHA256 of "<timestamp>.<raw body>" under the endpoint’s secret. Recompute it over the exact bytes you received and compare in constant time. Reject timestamps older than a few minutes to close replays.
import hmac, hashlib
def verify(secret: bytes, timestamp: str, body: bytes, signature: str) -> bool:
mac = hmac.new(secret, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, signature)
Outbound URLs are checked against private and loopback ranges before delivery, so a webhook cannot be pointed at something inside the daemon’s own network.
Delivery log
GET /v1/projects/{id}/integrations/{iid}/deliveries?limit= lists attempts, newest first: event, alert_id, status, attempts, last_error, next_attempt_at. The app shows the same list under the integration.
Delivery is at-least-once through a durable outbox. A failed attempt retries with backoff starting at 30 seconds and doubling up to an hour. After ten consecutive failures the integration is disabled and stops trying; re-enable it with POST /v1/projects/{id}/integrations/{iid}/enable once the far end is fixed, which also clears the failure streak.