The shape of a delivery
A Pingoru webhook delivery is a single HTTP POST with a JSON
body and four custom headers. There's nothing exotic — anything that can
receive a JSON POST can be a Pingoru webhook receiver, including raw
Slack, Discord, and Microsoft Teams incoming-webhook URLs (they work
out of the box, no extra config).
Headers
| Header | Example | What it's for |
|---|---|---|
Content-Type | application/json | The body is always JSON. |
User-Agent | Pingoru-Webhook/1.0 | Identifies traffic from Pingoru in your logs. |
X-Pingoru-Event | incident.opened | The event type. See the list below. |
X-Pingoru-Signature | sha256=<hex> | HMAC-SHA256 of the raw body, using your channel's signing secret. |
X-Pingoru-Delivery | 3a7f9c1b8d2e4a55 | A unique 16-char hex id per delivery attempt. Use it for idempotency on your side. |
You can also configure additional static headers in the channel
settings (e.g. an Authorization: Bearer … token for a
PagerDuty Events v2 endpoint). Anything you set there is sent on
every delivery, alongside the headers above.
Event types
| Event | Fires when |
|---|---|
test.ping | You click "Send test" in the channel settings. Useful for one-time receiver smoke checks. |
incident.opened | A new incident is detected on a monitored provider. Fires once per incident. |
incident.updated | The vendor posts a follow-up update to an open incident, OR the impact / status changes. Off by default in new notification groups. |
incident.resolved | The vendor marks the incident resolved, OR our 6h stale-sweep auto-resolves an incident the vendor stopped reporting. |
component.status_changed | A specific component flips status (e.g. operational → degraded_performance). Reserved for advanced setups; not enabled by default. |
Payload — incident events
All three incident events (opened, updated, resolved) share the same body shape. Fields that don't
apply to a given event (e.g. resolved_at on an incident.opened) come through as null.
{
"provider": {
"id": 1417,
"slug": "stripe",
"name": "Stripe",
"logo_url": "/logos/stripe"
},
"incident": {
"id": 9824,
"source_id": "abc123def456",
"title": "Elevated error rates on the Stripe API",
"impact": "major",
"status": "investigating",
"url": "https://status.stripe.com/incidents/abc123",
"started_at": "2026-04-29T13:42:01+00:00",
"resolved_at": null,
"affected_component_source_ids": ["api", "dashboard"],
"latest_update": {
"body": "We're seeing elevated 5xx rates on api.stripe.com and are investigating.",
"status": "investigating"
}
},
"content": "🟠 **Stripe** — new incident: Elevated error rates on the Stripe API\nhttps://status.stripe.com/incidents/abc123",
"text": "🟠 **Stripe** — new incident: Elevated error rates on the Stripe API\nhttps://status.stripe.com/incidents/abc123"
} Field reference
| Path | Type | Notes |
|---|---|---|
provider.id | integer | Pingoru's internal provider id. Stable. |
provider.slug | string | The URL slug, e.g. amazon-web-services. Stable. |
provider.name | string | Human display name. |
provider.logo_url | string | null | Absolute or root-relative URL to a 256×256 PNG. |
incident.id | integer | Pingoru's internal incident id. Stable for the lifetime of the incident; the same id is used across opened → updated → resolved. |
incident.source_id | string | The vendor's own incident identifier (whatever upstream uses). Useful for cross-referencing the source page. |
incident.title | string | Whatever the vendor titled the incident. |
incident.impact | enum | One of none, minor, major, critical, maintenance. |
incident.status | enum | One of investigating, identified, monitoring, resolved, scheduled, in_progress, completed, postmortem. |
incident.url | string | null | Deep link to the vendor's incident page when available. |
incident.started_at | ISO 8601 timestamp | When the vendor says the incident started. Always present. |
incident.resolved_at | ISO 8601 timestamp | null | null for opened / updated; set on resolved. |
incident.affected_component_source_ids | array of string | Vendor component ids touched by the incident. Empty array when the source page doesn't break things down by component. |
incident.latest_update | object | null | The most recent vendor-posted update on the incident, with body and status. Null if the incident has no updates yet. |
content / text | string | A pre-rendered single-line summary. Slack, Discord, and Teams incoming webhooks read these so a plain channel URL renders nicely without any custom receiver code. Custom receivers can ignore them. |
Payload — component events
component.status_changed uses a different shape — it's
scoped to a single component, not an incident.
{
"provider": {
"id": 1417,
"slug": "stripe",
"name": "Stripe",
"logo_url": "/logos/stripe"
},
"component": {
"id": 50231,
"source_id": "api",
"name": "API",
"previous_status": "operational",
"status": "degraded_performance"
}
} previous_status and status are both drawn
from this set: operational, degraded_performance, partial_outage, major_outage, under_maintenance, unknown.
Verifying the signature
Every channel has a signing secret you can copy from the channel
settings page. We HMAC-SHA256 the exact bytes of the request body
with that secret and put the hex digest in X-Pingoru-Signature.
The body bytes are deterministic: keys are sorted alphabetically and
there's no whitespace between separators ({"a":1,"b":2},
not {"a": 1, "b": 2}). That means you can recompute
the digest from the raw body without re-serialising — and you should,
because re-serialising is what breaks signature verification 90% of
the time. Read the body bytes once, hash, then parse.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.PINGORU_SIGNING_SECRET;
// Capture the raw body BEFORE express.json() touches it.
app.use(express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}));
app.post('/pingoru-webhook', (req, res) => {
const got = req.get('x-pingoru-signature') || '';
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(req.rawBody)
.digest('hex');
// Constant-time compare to avoid timing leaks.
const a = Buffer.from(got);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature');
}
const event = req.get('x-pingoru-event');
const delivery = req.get('x-pingoru-delivery');
console.log(event, delivery, req.body);
res.status(204).end();
}); Python (Flask)
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['PINGORU_SIGNING_SECRET'].encode()
@app.post('/pingoru-webhook')
def hook():
raw = request.get_data() # bytes, exactly as Pingoru sent
got = request.headers.get('X-Pingoru-Signature', '')
expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(got, expected):
abort(401)
event = request.headers['X-Pingoru-Event']
delivery = request.headers['X-Pingoru-Delivery']
payload = request.get_json()
print(event, delivery, payload)
return '', 204 Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
var secret = []byte(os.Getenv("PINGORU_SIGNING_SECRET"))
func hook(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(r.Header.Get("X-Pingoru-Signature")), []byte(expected)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusNoContent)
} Delivery, retries, and idempotency
- Timeout: each delivery has an 8 second wall clock. If your endpoint takes longer than that we count the attempt as failed.
- Success: any 2xx response code. We don't read the response body; just acknowledge fast.
- Failure: any 4xx, 5xx, timeout, DNS error, or TLS failure. Pingoru does not currently auto-retry failed deliveries — the failure is recorded against the channel and surfaced in the dashboard. Plan for the receiver to be available; if it has a transient blip, the next event will fire normally and you can replay the missing one from the incident page.
- Idempotency:
X-Pingoru-Deliveryis unique per attempt. Use it as a dedup key on your side if you want belt-and-braces safety against double-processing. - Ordering: not guaranteed. Two events fired close together may arrive out of order. The
started_at/resolved_attimestamps in the payload are authoritative; don't rely on arrival order. - Per-event dedup on our side: each (channel, event) pair sends exactly once. If you have the same channel attached via two routes — directly to a monitor AND via a notification group that covers it — you'll still only get one POST.
Slack, Discord, and Teams without writing any code
Paste any incoming-webhook URL from those three apps into Pingoru's
channel settings and they Just Work. We include both content (Discord) and text (Slack / Teams) in every payload with
the same human-readable summary, so a default channel URL renders a
decent message without any custom formatting on your end.
If you want richer formatting (Slack blocks, Discord embeds), point the webhook at your own backend instead, format the payload there, and POST to the Slack / Discord / Teams URL yourself.
Testing a webhook
In Notifications → Channels → your webhook → Send test,
Pingoru fires a test.ping event with a stub payload. The
test uses the same signing flow as a real event, so a successful test
proves both the URL and the signature path.
{
"provider": {
"id": null,
"slug": null,
"name": null,
"logo_url": null
},
"test": true,
"content": "🦘 Pingoru webhook test — connection OK.",
"text": "🦘 Pingoru webhook test — connection OK."
} What to do when a delivery fails
Failed deliveries appear on the channel detail page along with the HTTP status, error message, and the first 500 characters of the response body we got back. Common causes and fixes:
| Symptom | What it means |
|---|---|
HTTP 401 with body containing "signature" | Your verifier and our hash disagree on the body bytes. Almost always: you're hashing the parsed-and-reserialised body instead of the raw bytes. Read the body as bytes, hash, then parse. |
HTTP 403 | WAF / Cloudflare rule blocking us. Allowlist the Pingoru-Webhook/1.0 User-Agent. |
HTTP 404 sustained | The endpoint moved. Update the channel's URL. |
timeout | Receiver took longer than 8 seconds. Acknowledge fast (return 2xx in <1s) and queue any heavy processing for after the response. |
HTTP 5xx | Your service errored. We don't auto-retry; fix the bug, replay from the incident page if needed. |
Rotating the signing secret
The signing secret is generated once when you create the channel. If it leaks (committed to a public repo, etc.), rotate it from the channel settings page — generates a new secret server-side, the old one stops verifying immediately. There's no overlap window, so plan to update your receiver in the same minute.
Didn't find what you needed? Let us know — we'll add it to the guides.