The Problem AlertManager Doesn't Solve
You have Prometheus firing alerts. You have AlertManager routing them. You have a team of engineers who need to be paged -- but not all at once, and not the same person every night for a month.
AlertManager is excellent at aggregating, deduplicating, and routing alerts based on labels. What it does not do is know who is on-call right now. There is no concept of a rotation, an escalation policy, or a schedule inside AlertManager's configuration. The receiver you define is a static target: a Slack channel, an email address, a webhook URL. It does not change based on the day of the week or which engineer is currently holding the pager.
This is a deliberate design decision. AlertManager handles signal routing. On-call scheduling is a separate concern. But if you run your own Prometheus stack and want on-call rotation without paying for a full observability platform, you need to bridge these two systems yourself.
The approach: configure AlertManager to fire at a webhook endpoint, run a thin adapter that normalizes the payload, and have Alert24 receive it, create an incident, and page whoever is currently on-call. The rotation lives in Alert24. AlertManager never needs to know who is on-call, and you never touch AlertManager config when your team rotates.
How AlertManager Webhook Config Works
AlertManager's webhook_config is the simplest receiver type. It sends an HTTP POST to a URL with a JSON body describing the alert group. A minimal configuration looks like this:
route:
receiver: oncall-webhook
group_by: ['alertname', 'severity']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: oncall-webhook
webhook_configs:
- url: 'https://adapter.yourcompany.com/alertmanager'
send_resolved: true
http_config:
bearer_token: 'your-shared-secret'
The payload AlertManager sends looks like this (abbreviated):
{
"version": "4",
"groupKey": "{}:{alertname=\"HighErrorRate\"}",
"status": "firing",
"receiver": "oncall-webhook",
"alerts": [
{
"status": "firing",
"labels": {
"alertname": "HighErrorRate",
"severity": "critical",
"service": "payments"
},
"annotations": {
"summary": "Error rate above 5% for 10 minutes",
"runbook_url": "https://wiki.yourcompany.com/runbooks/high-error-rate"
},
"startsAt": "2026-05-28T14:23:00Z"
}
]
}
One thing to note: AlertManager batches alerts into groups, so a single webhook call may contain multiple firing alerts. Your adapter needs to handle that.
The Adapter Pattern
Alert24 accepts incidents via its REST API. The adapter's job is to translate AlertManager's payload format into Alert24's incident format. It also handles deduplication -- if AlertManager sends the same alert again (it will, on repeat_interval), you don't want to create a new incident each time.
Here is a Node.js adapter that covers the essential cases:
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());
const ALERT24_API_KEY = process.env.ALERT24_API_KEY;
const ALERT24_INTEGRATION_KEY = process.env.ALERT24_INTEGRATION_KEY;
const SHARED_SECRET = process.env.SHARED_SECRET;
app.post('/alertmanager', async (req, res) => {
const authHeader = req.headers['authorization'] || '';
if (authHeader !== `Bearer ${SHARED_SECRET}`) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { status, alerts, groupKey } = req.body;
for (const alert of alerts) {
const { alertname, severity, service } = alert.labels;
const summary = alert.annotations?.summary || alertname;
const dedupeKey = `${groupKey}-${alertname}`;
if (alert.status === 'firing') {
await axios.post(
'https://app.alert24.io/api/v1/incidents',
{
integration_key: ALERT24_INTEGRATION_KEY,
event_action: 'trigger',
dedup_key: dedupeKey,
payload: {
summary: summary,
severity: severity === 'critical' ? 'critical' : 'warning',
source: service || 'prometheus',
custom_details: alert.labels,
},
links: alert.annotations?.runbook_url
? [{ href: alert.annotations.runbook_url, text: 'Runbook' }]
: [],
},
{ headers: { 'Content-Type': 'application/json' } }
);
} else if (alert.status === 'resolved') {
await axios.post(
'https://app.alert24.io/api/v1/incidents',
{
integration_key: ALERT24_INTEGRATION_KEY,
event_action: 'resolve',
dedup_key: dedupeKey,
},
{ headers: { 'Content-Type': 'application/json' } }
);
}
}
res.status(200).json({ ok: true });
});
app.listen(3000, () => console.log('Adapter listening on port 3000'));
The dedup_key is the critical piece. Alert24 uses it to match a resolve event back to the original incident. If you omit it or compute it inconsistently, you'll create duplicate incidents or fail to auto-resolve them when Prometheus recovers.
A Python equivalent using FastAPI follows the same structure: parse the body, iterate over alerts, POST to Alert24 with trigger or resolve based on alert.status, and use a stable dedup key derived from the group key and alert name.
Mapping Alert Severity to On-Call Urgency
AlertManager typically sets severity labels like info, warning, and critical. Alert24 uses these to determine urgency, which in turn controls which notification rules fire -- phone call for critical at 3 AM, Slack message for warning during business hours.
Here is a clean mapping to apply in your adapter:
| AlertManager Severity | Alert24 Severity | Default Behavior |
|---|---|---|
| critical | critical | Phone + SMS immediately |
| warning | warning | SMS + email, escalate if no ack in 15 min |
| info | low | Email only, no paging |
You can override this mapping by examining other labels -- a warning on your payments service at 2 AM may warrant critical treatment even if the label says otherwise. The adapter is the right place to apply that logic.
How the On-Call Rotation Works
Once Alert24 receives the incident, it looks up the on-call schedule for the team assigned to that integration key. The schedule rotates automatically -- weekly, daily, follow-the-sun, whatever pattern your team uses. The right engineer gets paged without any changes to your AlertManager config or your adapter.
When the rotation changes, you update it in Alert24's UI or via the Alert24 API. Nothing in Prometheus or AlertManager needs to change. This is the key benefit of the adapter pattern: AlertManager owns signal detection and routing by label; Alert24 owns schedule, escalation, and incident tracking.
If the on-call engineer doesn't acknowledge within your configured window, Alert24 escalates to the secondary, then to the team lead. All of that escalation logic lives in Alert24's escalation policy, outside your infrastructure code.
Deploying the Adapter
The adapter is stateless and small. A few options:
Deploy it as a container alongside your Prometheus stack. Keep it in the same network segment so AlertManager can reach it without traversing the public internet. Use your existing secret management (Vault, Kubernetes secrets, environment variables in your container runtime) to inject ALERT24_API_KEY, ALERT24_INTEGRATION_KEY, and SHARED_SECRET.
If you run Kubernetes, a Deployment with two replicas behind a ClusterIP Service is sufficient. AlertManager's webhook config points to the Service DNS name. No persistent storage needed.
Next Steps
- Create an integration in Alert24 (type: Generic Webhook) and copy the integration key.
- Deploy the adapter with your credentials and confirm it responds to a test POST.
- Add the
webhook_configblock to your AlertManager configuration and reload withamtoolor a config map update. - Send a test alert using
amtool alert add alertname=TestAlert severity=criticaland verify it appears in Alert24 and pages your on-call. - Set up your on-call schedule in Alert24 -- weekly rotation, escalation policy, and per-user notification preferences.
AlertManager will keep doing what it does well. Alert24 handles who gets woken up and makes sure they're accountable for the incident until it is resolved.