Webhooks
Receive real-time notifications when teams are provisioned, deleted, when monitors change, when incidents occur in managed teams, and when platform emails are suppressed.
Setup
Option A: Convenience API (Recommended)
Create a webhook subscription in a single call:
curl -X POST https://app.hoppla.dev/api/v1/reseller/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Ops Integration",
"url": "https://your-server.com/hoppla-webhook",
"secret": "your-hmac-secret",
"events": [
"monitor.down",
"monitor.up",
"incident.created",
"incident.resolved",
"ssl.expiring",
"team.provisioned"
],
"tenant_filter": null
}'This creates both a notification channel and rule automatically. Manage with GET/PATCH/DELETE /reseller/webhooks/{id}. View delivery logs with GET /reseller/webhooks/{id}/deliveries.
Option B: Manual Setup (Advanced)
For full control over channel settings (quiet hours, rate limits, batch windows), use the standard channel + rule endpoints:
1. Create a Notification Channel
curl -X POST https://app.hoppla.dev/api/v1/notification-channels \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Provisioning Webhooks",
"type": "webhook",
"config": {
"url": "https://your-server.com/hoppla-webhook",
"secret": "your-hmac-secret"
},
"is_enabled": true
}'2. Create a Notification Rule
curl -X POST https://app.hoppla.dev/api/v1/notification-rules \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "channel-uuid-...",
"event_types": [
"team.provisioned",
"team.deleted",
"incident.created",
"incident.resolved",
"monitor.down",
"ssl.expiring"
],
"tenant_filter": {
"tenant_ids": ["uuid1", "uuid2"],
"labels": ["production-*"]
}
}'Tenant Filtering
Filter which managed tenants trigger events for each webhook:
| Filter | Description |
|---|---|
null / omitted | All managed tenants (default) |
tenant_ids | Explicit list of tenant UUIDs |
labels | Pattern match on managed team labels (supports trailing * wildcard) |
Filters are OR-combined: an event matches if it matches any tenant_id OR any label pattern.
Complete Event Reference
Team Management Events
| Event | Trigger | Key Payload Fields |
|---|---|---|
team.provisioned | Team created via API or portal registration | managed_team_id, tenant_id, tenant_name, label |
team.deleted | Team soft-deleted | managed_team_id, tenant_id, tenant_name |
team.paused | Monitoring paused for a team | managed_team_id, tenant_id, reason |
team.unpaused | Monitoring resumed | managed_team_id, tenant_id |
team.trial_ending | Trial expires in 3 days or 1 day | managed_team_id, tenant_id, plan, trial_ends_at, days_remaining |
team.trial_expired | Trial period ended, plan reverted to free | managed_team_id, tenant_id, previous_plan |
tenant.quota_warning | A managed team reaches ≥80% of a plan limit (e.g. monitors) | tenant_id, feature, current_count, limit, usage_pct |
tenant.quota_exceeded | A create was blocked because a managed team hit a plan limit | tenant_id, feature, current_count, limit, usage_pct |
Quota events are deduplicated per team + feature (one
tenant.quota_warningper day, onetenant.quota_exceededper 6 hours) so a batch import does not flood your webhook.
Monitor Events
| Event | Trigger |
|---|---|
monitor.created | Monitor created in a managed team |
monitor.updated | Monitor updated in a managed team |
monitor.deleted | Monitor deleted in a managed team |
monitor.down | Monitor went down (convenience alias for incident.created) |
monitor.up | Monitor recovered (convenience alias for incident.resolved) |
monitor.enabled | Monitor enabled (was paused/disabled) |
monitor.disabled | Monitor disabled/paused |
monitor.flapping | Monitor is flapping (rapidly toggling up/down) |
monitor.flapping_resolved | Monitor stopped flapping and stabilized |
ssl.expiring | SSL certificate expiring within threshold (alias for warning.ssl_expiring) |
Member Events
| Event | Trigger |
|---|---|
member.added | User added to a managed team |
member.removed | User removed from a managed team |
member.erased | A member's personal data was erased under GDPR Art. 17 |
member.erasedis notmember.removed. Removal frees a seat: the person still exists and can be re-invited. Erasure destroys their identity across the whole platform — login, passkeys, device tokens, and every team they belonged to, including teams you do not manage. If your CRM treatsmember.removedas "mark the seat free, keep the contact", that is the wrong handling here: the contact record is what has to go.It fires whether the person erased themselves from their own account settings or you erased them on their behalf via
POST /reseller/managed-teams/{managedTeam}/members/{userId}/erase. The payload'sinitiated_byisselforreseller.This event carries the e-mail address deliberately — it is the only key you can match against your own records, and it is the last message that can carry it.
Monitoring Events (Forwarded)
All monitoring events from your managed teams are automatically forwarded to your notification channels. These are the same events your managed teams' users receive — you always have full visibility.
| Category | Event Types |
|---|---|
| Incidents | incident.created, incident.resolved |
| Warnings | warning.ssl_expiring, warning.domain_expiring, warning.degraded_performance, warning.application_health, warning.sitemap_issues, warning.security_headers, warning.redirect_chain, warning.robots_txt, warning.dnsbl_listing, warning.broken_links, warning.mixed_content, warning.server_resources, warning.cleared |
| DNS | dns.records_changed, dns.nameserver_changed |
| Certificates | certificate.renewed |
| Storms | storm.detected, storm.resolved |
dns.nameserver_changedis a convenience alias fired in addition todns.records_changedwhenever a confirmed DNS change includes a change to the authoritative nameserver set (an NS-hijack signal). It carries the same payload asdns.records_changed. Like all forwarded DNS events, it is delivered to the reseller independently of the managed customer'snotify_dns_changestoggle — a customer silencing their own DNS alerts never blinds the reseller to a nameserver change on their fleet.
Email Suppression Events
When you suppress platform emails for your managed teams, Hoppla fires email.* webhooks containing the full email payload — so you can send the emails yourself.
| Event | Suppressed Email |
|---|---|
email.password_reset | Never fired — password-reset mail cannot be suppressed. |
email.email_change_verify | Never fired — address-change verification cannot be suppressed. |
email.email_changed_notify | Never fired — the notice to the old address cannot be suppressed. |
email.invitation | Suppressed team invitation mail. |
email.member_removed | Suppressed notice that a member was removed. |
email.trial_ending | Suppressed trial-ending reminder. |
email.trial_expired | Suppressed trial-expired notice. |
email.incident_created | Suppressed incident-opened alert. |
email.incident_resolved | Suppressed incident-resolved all-clear. |
email.warning | Suppressed advisory-warning mail. |
email.dns_changed | Suppressed DNS-change notice. |
email.certificate_renewed | Suppressed certificate-renewal notice. |
email.digest | Suppressed periodic digest. |
email.monthly_report | Suppressed monthly monitoring report. |
email.status_page_subscription | Suppressed status-page update to a subscriber. |
email.security_mfa_enabled | Suppressed notice that two-factor authentication was switched on. |
email.security_mfa_disabled | Suppressed notice that two-factor authentication was switched off. |
email.security_recovery_codes_regenerated | Suppressed notice that recovery codes were replaced, invalidating the old set. |
email.security_mfa_reset | Suppressed notice that two-factor authentication was reset by an administrator. |
email.security_passkey_added | Suppressed notice that a passkey was registered to the account. |
email.security_passkey_removed | Suppressed notice that a passkey was removed from the account. |
email.security_password_changed | Suppressed notice that the account password was changed. |
email.security_email_changed | Suppressed notice that the account email address was changed. |
email.security_api_token_created | Suppressed notice that an API token was issued. |
email.security_api_token_revoked | Suppressed notice that an API token was revoked. |
email.security_session_revoked | Suppressed notice that a signed-in session was ended remotely. |
email.security_login_new_device | Suppressed notice of a sign-in from a device not seen before. |
email.security_account_blocked | Suppressed notice that the account was blocked from signing in. |
email.security_account_unblocked | Suppressed notice that a block on the account was lifted. |
email.security_role_changed | Suppressed notice that the account's role in a team changed. |
email.security_auth_policy_changed | Suppressed notice that the team's sign-in policy changed. |
email.security_account_deleted | Suppressed notice that the account was deleted. |
Suppression requires a listener
An email is only suppressed if you have an enabled webhook channel with a rule subscribed to that exact email.* event. Without one the platform email is sent to the user as normal, rather than being dropped with nothing in its place. Subscribe first, then enable suppression.
The auth class is never suppressed
email.password_reset, email.email_change_verify and email.email_changed_notify are listed for completeness and never fire. Their template data is the credential itself — a working reset or verification link — so delivering it to a reseller would be an account takeover the user is never told about. Use impersonation if you need to act on a managed account; that route is audited as impersonation.
Payload Examples
Team Provisioned
{
"event": "team.provisioned",
"timestamp": "2026-01-15T10:30:00.000000Z",
"managed_team_id": "mt-uuid-...",
"tenant_id": "new-tenant-uuid-...",
"tenant_name": "Acme Corp",
"label": "Enterprise Client #42"
}Team Trial Ending
{
"event": "team.trial_ending",
"timestamp": "2026-01-28T00:00:00.000000Z",
"managed_team_id": "mt-uuid-...",
"tenant_id": "tenant-uuid-...",
"tenant_name": "Acme Corp",
"label": "Enterprise Client #42",
"plan": "pro",
"trial_ends_at": "2026-01-31T00:00:00.000000Z",
"days_remaining": 3
}Member Erased
{
"event": "member.erased",
"email": "[email protected]",
"name": "A Person",
"erased_at": "2026-09-22T08:14:02+00:00",
"initiated_by": "self",
"erasure_id": "01a0c7...",
"teams": [
{
"managed_tenant_id": "01a0c7...",
"user_id": "01a0c7...",
"role": "admin",
"label": "acme-prod",
"external_ref": "CRM-4711"
}
]
}One event per reseller, not per team: if the person belonged to several of your teams, every one is listed under teams. A managed team is never deleted by an erasure, even when the erased person was its last member — it is left in place with no members so the decision to close or re-staff it stays yours.
Forwarded Incident
Forwarded monitoring events include a _managed_tenant object so you can identify which team triggered the event:
{
"event": "incident.created",
"timestamp": "2026-01-15T14:00:00.000000Z",
"incident_id": "inc-uuid-...",
"monitor_id": "monitor-uuid-...",
"monitor_name": "Acme Website",
"monitor_url": "https://acmecorp.com",
"title": "HTTP 503 Service Unavailable",
"status": "investigating",
"severity": "major",
"started_at": "2026-01-15T14:00:00.000000Z",
"resolved_at": null,
"_managed_tenant": {
"managed_tenant_id": "tenant-uuid-...",
"name": "Acme Corp",
"label": "Enterprise Client #42"
}
}Forwarded DNS Change
Both dns.records_changed and dns.nameserver_changed carry the same zone-aware payload. nameserver_change is null (and nameservers_changed is false) for a routine record change; when the authoritative nameserver set changes it describes the before/after delegation:
{
"event": "dns.nameserver_changed",
"timestamp": "2026-01-15T16:00:00.000000Z",
"monitor_id": "monitor-uuid-...",
"monitor_name": "Acme Website",
"monitor_url": "https://acmecorp.com",
"check_type": "dns",
"zone": "acmecorp.com",
"nameservers": ["ns1.evil-host.com", "ns2.evil-host.com"],
"changes": [
{ "type": "NS", "old": ["ns1.acme-dns.com", "ns2.acme-dns.com"], "new": ["ns1.evil-host.com", "ns2.evil-host.com"] }
],
"nameserver_change": {
"old": ["ns1.acme-dns.com", "ns2.acme-dns.com"],
"new": ["ns1.evil-host.com", "ns2.evil-host.com"]
},
"nameservers_changed": true,
"_managed_tenant": {
"managed_tenant_id": "tenant-uuid-...",
"name": "Acme Corp",
"label": "Enterprise Client #42"
}
}Email Suppression Webhook
When a platform email is suppressed, the webhook contains everything you need to send the email yourself:
{
"event": "email.security_password_changed",
"timestamp": "2026-01-15T15:00:00.000000Z",
"email_type": "security_password_changed",
"recipient": {
"email": "[email protected]",
"name": "John Doe",
"language": "en",
"user_id": "user-uuid-..."
},
"template_data": {
"user_name": "John Doe",
"user_email": "[email protected]",
"brand_name": "Acme Monitoring",
"occurred_at": "2026-01-15T14:59:58+00:00",
"ip_address": "203.0.113.10"
},
"_managed_tenant": {
"managed_tenant_id": "tenant-uuid-...",
"name": "Acme Corp",
"label": "Enterprise Client #42"
}
}This example used to show email.password_reset carrying a working reset_url. That event does not fire and that payload is never sent — see the note on the auth class above.
Key Behaviors
- Same event type names — resellers subscribe to
incident.created, notreseller.incident.created. The_managed_tenantfield distinguishes forwarded events. - Monitor mute bypass — if a managed team mutes notifications for a monitor, you still receive the forwarded event. Resellers always have full visibility.
- Cached lookups — managed team lookups are cached for 5 minutes and automatically invalidated when relationships change.
Email Suppression
Resellers can suppress specific platform emails for all managed teams. When an email is suppressed, Hoppla fires an email.* webhook carrying the recipient and the template data instead of sending the mail, so you can send it yourself under your own branding.
Suppression is a redirect, not an off switch. It only takes effect when you are subscribed to the matching event, and the auth class is never suppressed at all — see the two notes above.
Configure Suppression
# View current config
curl https://app.hoppla.dev/api/v1/reseller/email-suppression \
-H "Authorization: Bearer $TOKEN"
# Update suppressed email types
curl -X PUT https://app.hoppla.dev/api/v1/reseller/email-suppression \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"suppressed_types": [
"invitation",
"member_removed",
"incident_created",
"incident_resolved",
"warning"
]
}'How It Works
- You configure which email types to suppress via the API or dashboard
- When Hoppla would normally send one of these emails to a managed team user, it checks your suppression config
- If the email type is suppressed, Hoppla fires an
email.*webhook instead - Your system receives the webhook with the full email payload and can send the email using your own templates and branding
- Non-email notification channels (Slack, Telegram, etc.) are not affected — they continue to fire normally
Important Notes
- Suppression applies to all your managed teams (reseller-wide setting)
- Only the email channel is suppressed — other notification channels still work normally
- The payload carries the template data for the mail you are replacing. It never carries password-reset or address-verification links, because those emails cannot be suppressed
- A subscription is required. Suppressing a type you are not subscribed to has no effect: the platform sends the email as normal. This is deliberate — the alternative is a message that reaches nobody
- If your endpoint is down the email is still suppressed (there is no fallback to platform email), so treat delivery failures on
email.*as user-visible
Delivery
- Events are dispatched asynchronously in the background
- Failed deliveries are retried 3 times with backoff (10s, 60s, 300s)
- HTTP delivery timeout: 10 seconds — your endpoint must respond within 10s or the delivery is treated as failed and retried
- Delivery status is logged and viewable via the notification logs API
Supported Channels
Reseller events can be delivered through any notification channel type:
| Channel | Description |
|---|---|
| Webhook | HTTP POST with JSON payload and optional HMAC signature |
| Event details sent to configured recipients | |
| Slack | Message posted via incoming webhook |
| Discord | Message posted via Discord webhook |
| Microsoft Teams | Message posted via Teams incoming webhook |
| PagerDuty | Event routed via PagerDuty Events API |
| Telegram | Message sent via Telegram Bot API |
Verifying Webhook Signatures
If you configured a secret on your webhook channel, each request includes an X-Webhook-Signature header with an HMAC-SHA256 signature. (The former X-Hoppla-Signature header was removed on 28 September 2026; it carried the same value.)
Verify against the raw request body — not a re-serialized object. The signature is computed over the exact bytes Hoppla sends. Hoppla builds that body by sorting the top-level keys alphabetically and serializing JSON without escaping forward slashes. If you parse the JSON and re-encode it before verifying, the bytes will differ (key order, slash escaping, whitespace) and verification will fail. Always read the raw request body and pass those bytes directly to your HMAC function.
import hmac
import hashlib
# payload_body MUST be the raw request bytes, before any JSON parsing
def verify_signature(payload_body: bytes, secret: str, signature_header: str) -> bool:
expected = hmac.new(
secret.encode(),
payload_body,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)// Read the raw body, e.g. file_get_contents('php://input') — do not re-encode a decoded array
$expected = hash_hmac('sha256', $rawRequestBody, $secret);
$valid = hash_equals($expected, $signatureHeader);