Webhooks
Send every alert to your own endpoint as JSON. A webhook is a notification channel like Slack or e-mail, so anything you can route to a channel you can route to a webhook: incidents, advisory warnings, DNS changes, certificate renewals and account-security events.
Not the reseller webhooks
This page is the customer-facing contract. Hoppla also has a reseller webhook system with its own catalogue — team.*, member.*, email.* suppression — described in Reseller Webhooks. Those events are only delivered to reseller plans, and the reseller API is likewise reseller-only. Everything on THIS page works on any paid plan.
Set one up
- Settings → Notification Channels → Add Channel, type Webhook.
- Paste the URL that should receive the POST.
- Optionally set an HMAC Secret. Do set one — without it you cannot prove a request came from Hoppla.
- Send Test delivers a real request immediately, so you can confirm the endpoint before an incident depends on it.
- Point a notification rule at the channel to choose which events it receives.
Your URL must be publicly resolvable over HTTP(S). Private, link-local and loopback addresses are rejected when the channel is saved and again at delivery — Hoppla will not call localhost, 10.0.0.0/8, 169.254.0.0/16 or friends, which also means a tunnel is needed for local testing.
The request
POST, Content-Type: application/json, 10-second timeout.
Only a 2xx counts as delivered. Anything else — a 4xx, a 5xx, or a redirect, which we do not follow — is a failure, and so is a timeout or a connection error.
What happens next depends on the code. A 5xx, 429, 408 or 425, or a connection failure, is treated as a bad moment on your side and retried: three attempts at 10s, 60s and 300s. Any other 4xx says the request itself was wrong — a 404 for a URL that moved, a 422 for a payload your handler rejected — and is not retried, because the next two attempts would be identical. Either way the delivery is recorded, with the status your endpoint returned, under Notifications → Logs. Each row there has a Send again button: it replays that delivery with the same X-Webhook-Delivery id, so if your endpoint already handled the first attempt it will recognise the repeat and drop it. Pressing it when you are not sure whether something landed is safe.
Every request carries an X-Webhook-Delivery header, repeated as delivery_id in the body. It identifies one delivery and does not change between retries, so it is the key to deduplicate on. See Delivering more than once.
The User-Agent identifies the sending platform, for example Hoppla-Webhook/1.0. On a white-label portal it carries that portal's brand instead.
The body is a flat JSON object — no nested monitor or incident objects — with keys sorted alphabetically. Every payload carries event and timestamp; the rest depends on the event.
{
"check_type": "uptime",
"delivery_id": "018f3c2a-7b41-7c3e-9a55-2f9b1d4e6a10",
"event": "incident.created",
"incident_id": "9f1c…",
"monitor_id": "3ab7…",
"monitor_name": "example.com",
"monitor_url": "https://example.com",
"severity": "critical",
"started_at": "2026-03-04T12:00:00+00:00",
"status": "down",
"timestamp": "2026-03-04T12:00:03+00:00",
"title": "HTTP 503 Service Unavailable"
}Fields are drawn from a fixed allowlist, so a payload never carries anything from your account that the event did not need: incident_id, monitor_id, monitor_name, monitor_url, title, status, severity, started_at, resolved_at, check_type, event_type, timestamp, zone, nameservers, changes, nameserver_change, nameservers_changed, ssl_issuer, ssl_valid_from, ssl_valid_to, response_time_ms, status_code, cleared_warning, brand_name. security.* events carry their own curated payload and never include credential values — only the name of the thing that changed.
Verifying the signature
When the channel has a secret, Hoppla signs the request:
X-Webhook-Signature: <hex>Until 28 September 2026 deliveries also carried the same value in X-Hoppla-Signature (and X-Hoppla-Timestamp). That pair has been removed. A receiver that still reads it only needs to read X-Webhook-Signature (and X-Webhook-Timestamp) instead; the value is identical.
The signature is HMAC-SHA256 of the raw request body, hex-encoded:
signature = hmac_sha256(secret, raw_request_body)Sign the bytes you received, not a re-serialised object. Hoppla sends the JSON with forward slashes unescaped and keys sorted; parsing and re-encoding will change the bytes and the signature will not match.
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, $secret);
if (! hash_equals($expected, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}import { createHmac, timingSafeEqual } from 'node:crypto'
// express.raw({ type: 'application/json' }) — req.body must be a Buffer
const expected = createHmac('sha256', secret).update(req.body).digest('hex')
const got = req.get('x-webhook-signature') ?? ''
const ok = expected.length === got.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(got))
if (!ok) return res.sendStatus(401)import hmac, hashlib
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")):
abort(401)Compare in constant time (hash_equals, timingSafeEqual, compare_digest), not with ==.
Events
Every event below can be routed to a webhook channel. The same list is shown in the product under Developers, generated from the same source as this page.
certificate
| Event | Meaning |
|---|---|
certificate.renewed | A TLS certificate was replaced with a new one. |
dns
| Event | Meaning |
|---|---|
dns.records_changed | A watched DNS record changed value. |
incident
| Event | Meaning |
|---|---|
incident.created | An incident was opened for a monitor. |
incident.resolved | An open incident was resolved and the all-clear sent. |
monitor
| Event | Meaning |
|---|---|
monitor.flapping | A monitor changed state repeatedly in a short window. |
monitor.flapping_resolved | A flapping monitor settled. |
security
| Event | Meaning |
|---|---|
security.mfa_enabled | A member turned on multi-factor authentication. |
security.mfa_disabled | A member turned off multi-factor authentication. |
security.recovery_codes_regenerated | A member regenerated their recovery codes, invalidating the old set. |
security.mfa_reset | An administrator reset a member’s multi-factor authentication. |
security.passkey_added | A member registered a passkey. |
security.passkey_removed | A passkey was removed from an account. |
security.password_changed | A member changed their password. |
security.email_changed | A member’s e-mail address was changed. |
security.api_token_created | An API token was issued. |
security.api_token_revoked | An API token was revoked. |
security.session_revoked | A session was signed out remotely. |
security.login_new_device | A sign-in came from a device this account has not used before. |
security.account_blocked | An account was blocked and can no longer sign in. |
security.account_unblocked | A blocked account was restored. |
security.role_changed | A member’s role changed, altering what they may do. |
security.auth_policy_changed | The team’s authentication policy changed (SSO, MFA enforcement). |
security.account_deleted | An account was deleted. For GDPR erasure see member.erased. |
storm
| Event | Meaning |
|---|---|
storm.detected | Many monitors failed at once — usually shared infrastructure, not many faults. |
storm.resolved | A detected storm has passed. |
warning
| Event | Meaning |
|---|---|
warning.ssl_expiring | A TLS certificate is approaching expiry. |
warning.domain_expiring | A domain registration is approaching expiry. |
warning.degraded_performance | Response times are consistently worse than the monitor’s threshold. |
warning.slow_response | A single check exceeded the slow-response threshold. |
warning.application_health | An application health endpoint reported a problem. |
warning.sitemap_issues | The sitemap is unreachable, malformed, or contains dead URLs. |
warning.security_headers | Expected security headers are missing or weakened. |
warning.redirect_chain | A redirect chain grew too long, loops, or drops to plain HTTP. |
warning.robots_txt | robots.txt changed, disappeared, or now blocks crawling. |
warning.dnsbl_listing | The host appeared on a DNS blocklist. |
warning.broken_links | A crawl found links that no longer resolve. |
warning.mixed_content | An HTTPS page loads sub-resources over plain HTTP. |
warning.server_resources | An agent monitor reported CPU, memory or disk pressure. |
warning.cleared | A previously reported warning is no longer present. |
Delivering more than once
Delivery is at-least-once. A slow endpoint that eventually succeeds may already have processed the first attempt, so the same event can legitimately arrive twice.
Deduplicate on X-Webhook-Delivery (also delivery_id in the body). It is one value per delivery and it is identical on every retry of that delivery, so recording the ones you have handled and skipping repeats is enough:
$id = $request->header('X-Webhook-Delivery');
if (! Cache::add("webhook:{$id}", true, now()->addDay())) {
return response()->noContent(); // already handled
}A retry reuses the id; a genuinely new event gets a new one. Don't key on incident_id plus event — a monitor that recovers and fails again produces two real incident.created events you want both of.
Things worth knowing
- Order is not guaranteed. Two events raised in the same second may arrive in either order. Use
timestamprather than arrival order. - Answer fast. The timeout is 10 seconds; queue the work and return 2xx immediately rather than processing inline.
- A secret is optional but a missing one is unverifiable. Without it the request carries no signature at all, and anyone who learns your URL can post to it.
Limits
Every delivery is a POST with Content-Type: application/json. There is no way to change the method or add headers of your own, by design — the request is authenticated by its HMAC signature, not by something you paste into a header.
Don't put a token in the URL either. The channel's URL is shown in full to everyone on your team who can read the channel, so it is not a place to keep a secret. Use the signature above.