Help center

Webhooks

A webhook is a URL Pingoru POSTs to whenever an incident on one of your monitored providers opens, updates, or resolves. This page is the reference: what fires, what's in the body, how to verify the signature, and what to do when delivery fails.

← All guides

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

HeaderExampleWhat it's for
Content-Typeapplication/jsonThe body is always JSON.
User-AgentPingoru-Webhook/1.0Identifies traffic from Pingoru in your logs.
X-Pingoru-Eventincident.openedThe event type. See the list below.
X-Pingoru-Signaturesha256=<hex>HMAC-SHA256 of the raw body, using your channel's signing secret.
X-Pingoru-Delivery3a7f9c1b8d2e4a55A 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

EventFires when
test.pingYou click "Send test" in the channel settings. Useful for one-time receiver smoke checks.
incident.openedA new incident is detected on a monitored provider. Fires once per incident.
incident.updatedThe vendor posts a follow-up update to an open incident, OR the impact / status changes. Off by default in new notification groups.
incident.resolvedThe vendor marks the incident resolved, OR our 6h stale-sweep auto-resolves an incident the vendor stopped reporting.
component.status_changedA specific component flips status (e.g. operationaldegraded_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

PathTypeNotes
provider.idintegerPingoru's internal provider id. Stable.
provider.slugstringThe URL slug, e.g. amazon-web-services. Stable.
provider.namestringHuman display name.
provider.logo_urlstring | nullAbsolute or root-relative URL to a 256×256 PNG.
incident.idintegerPingoru's internal incident id. Stable for the lifetime of the incident; the same id is used across openedupdatedresolved.
incident.source_idstringThe vendor's own incident identifier (whatever upstream uses). Useful for cross-referencing the source page.
incident.titlestringWhatever the vendor titled the incident.
incident.impactenumOne of none, minor, major, critical, maintenance.
incident.statusenumOne of investigating, identified, monitoring, resolved, scheduled, in_progress, completed, postmortem.
incident.urlstring | nullDeep link to the vendor's incident page when available.
incident.started_atISO 8601 timestampWhen the vendor says the incident started. Always present.
incident.resolved_atISO 8601 timestamp | nullnull for opened / updated; set on resolved.
incident.affected_component_source_idsarray of stringVendor component ids touched by the incident. Empty array when the source page doesn't break things down by component.
incident.latest_updateobject | nullThe most recent vendor-posted update on the incident, with body and status. Null if the incident has no updates yet.
content / textstringA 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-Delivery is 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_at timestamps 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:

SymptomWhat 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 403WAF / Cloudflare rule blocking us. Allowlist the Pingoru-Webhook/1.0 User-Agent.
HTTP 404 sustainedThe endpoint moved. Update the channel's URL.
timeoutReceiver took longer than 8 seconds. Acknowledge fast (return 2xx in <1s) and queue any heavy processing for after the response.
HTTP 5xxYour 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.