Alert rules watch a server’s metrics and raise an alert when a value crosses a threshold, using the data the agent has already sent. Rules are evaluated on Alpacon’s own schedule, about once a minute—see Metric alerts for how a rule decides to fire and clear.
Requires the Metrics extension to be enabled for the workspace, for every action—including reading the seeded default disk-usage rule that every server carries regardless of plan. That rule is still evaluated on a workspace with the extension off; this API just isn’t how you’d read it there.
A workspace admin can update or delete any rule. So can anyone whose role carries the workspace-wide alert_rule:update (for PUT/PATCH) or alert_rule:delete permission—the built-in operator role has both, member has neither. Creating a rule doesn’t grant its creator any special access to it on its own.
Beyond those roles, an admin can give a specific person, group, or service token’s Application the right to update and delete one rule, without changing anyone’s workspace-wide role. That grant is an explicit rule-scoped alert_rule:maintainer binding, made through the RBAC role API—the console’s role pickers can’t scope a grant to a single rule yet. A service token reaches a rule only through a binding like this one; its Application’s workspace-wide roles, even operator, don’t reach a rule on their own. A personal API token acts as the person who issued it, so it follows their role and any binding they hold.
A rule created before Alpacon stopped granting this binding to creators automatically may still carry the binding its creator was given at the time, and that creator keeps updating and deleting the rule until Alpacon finishes cleaning up those older bindings.
The extension default rules—CPU usage, memory usage, and any other non-core rule marked default—can be updated by anyone whose role carries alert_rule:update, the same as any other rule. No default rule can be deleted while it’s still marked as default, whoever asks; see Delete alert rule.
Anyone else—an operator, a principal with a rule-scoped binding on it, and every service token—gets 403 metrics_alert_rule_core_admin_only. Attaching the rule back to a server isn’t restricted this way, and previewing what it would do (recipients or evaluation) stays open to anyone who can already read the rule.
List
List alert rules
Retrieve a paginated list of alert rules.
Request
GET /api/metrics/alert-rules/
Query parameters
| Parameter | Type | Description |
|---|
name | string | Filter by rule name |
owner | string | Filter by owner username |
target | string | Filter by target metric |
threshold | number | Filter by threshold |
operator | string | Filter by direction (gte or lte) |
device | string | Filter by device |
severity | string | Filter by severity (critical, warning, info) |
is_default | boolean | Filter by default status |
server | UUID | Only rules attached to this server |
exclude_server | UUID | Only rules not attached to this server |
search | string | Search across name, target, and default status |
ordering | string | Sort order. Default: -added_at |
page | integer | Page number |
page_size | integer | Results per page (default: 15, max: 100) |
Response
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": "3f1c9b2e-6a4d-4e8f-9b1a-2c3d4e5f6a7b",
"owner": null,
"name": "Core disk usage",
"target": "disk-usage",
"threshold": 80,
"operator": "gte",
"duration_s": 0,
"recovery_threshold": 78,
"no_data_after_s": null,
"device": "",
"severity": "warning",
"notify_email": "all",
"notify_slack_channel": true,
"is_default": true
},
{
"id": "8c4d6e2a-1b3f-4a5c-8d9e-0f1a2b3c4d5e",
"owner": {
"id": 1,
"name": "admin",
"email": "admin@example.com"
},
"name": "Sustained high CPU",
"target": "cpu-usage",
"threshold": 80,
"operator": "gte",
"duration_s": 300,
"recovery_threshold": 70,
"no_data_after_s": 600,
"device": "",
"severity": "critical",
"notify_email": "admins",
"notify_slack_channel": false,
"is_default": false
}
]
}
Required scope: alert_rule:read
Example
curl -X GET "https://your-workspace.us1.alpacon.io/api/metrics/alert-rules/?target=cpu-usage" \
-H "Authorization: token=\"alpat-xxxxxxxxxxxxxxxxxx\""
Create
Create alert rule
Create a new alert rule.
Request
POST /api/metrics/alert-rules/
Request body
{
"name": "Sustained high CPU",
"target": "cpu-usage",
"threshold": 80,
"operator": "gte",
"duration_s": 300,
"recovery_threshold": 70,
"no_data_after_s": 600,
"severity": "critical",
"notify_email": "admins",
"notify_slack_channel": false
}
Parameters
| Field | Type | Required | Description |
|---|
name | string | Yes | Rule name. Must be unique in the workspace |
target | string | Yes | Metric to watch: cpu-usage, memory-usage, disk-usage, peak-read-bps, peak-write-bps, avg-read-bps, avg-write-bps, peak-input-pps, peak-input-bps, peak-output-pps, peak-output-bps, avg-input-pps, avg-input-bps, avg-output-pps, avg-output-bps |
threshold | number | Yes | The value that raises the alert |
operator | string | No | gte—raise at or above the threshold (default)—or lte—raise at or below it |
duration_s | integer | No | How long the condition has to hold before the alert raises, in seconds. Default 0: the first breaching sample raises it. A positive value must be at least two collection intervals for the target—shorter is rejected with metrics_alert_rule_duration_too_short |
recovery_threshold | number | No | The value the metric has to reach before the alert clears. Must sit on the recovering side of threshold for the chosen operator—below it for gte, above it for lte. Leave empty and the threshold itself clears the alert |
no_data_after_s | integer | No | Raise a separate alert when no sample arrives for this many seconds. Must be at least this target’s collection interval and at most 86400 (24 hours). Leave empty to turn this off |
device | string | No | Limit the rule to one disk or network interface. Only valid on a device-scoped target—disk-usage and every disk I/O and network target above. Rejected on cpu-usage and memory-usage, which apply to the whole server |
severity | string | No | Severity the raised alert carries: critical, warning, or info. Default warning. Severity decides which channel can fire at all: only critical can email, per notify_email; critical and warning can post to Slack, per notify_slack_channel; info posts to neither. Every severity is still recorded in the console and the bell, delivered to any webhook subscribed to the rule’s events, and included in the hourly open alert digest while it’s open |
notify_email | string | No | Who the alert email is addressed to, within the people who can already see the server: all (default)—everyone who can see it; admins—workspace admins only; group_members—server group members and the owner; none—no individual alert email. Independent of notify_slack_channel, and doesn’t affect the separate open alert digest. Only takes effect on a critical-severity rule; a warning- or info-severity rule sends no mail whatever this says |
notify_slack_channel | boolean | No | Whether a raise and its resolution post to the workspace’s connected Slack channel. Default true. Independent of notify_email. Applies at critical and warning severity; an info-severity rule posts nothing whatever this says |
is_default | boolean | No | Whether this rule is automatically attached to every server registered afterward. Only one default rule per target and device |
Only a signed-in workspace member can set notify_email or notify_slack_channel—an application or service token gets metrics_alert_rule_destination_not_allowed if the request touches either field, even to send the same value the rule already has.
Creating a rule doesn’t grant the creator any special access to it—see Who can edit or delete a rule. A create that leaves the new rule with is_default: true and target disk-usage is refused with metrics_alert_rule_core_admin_only unless the caller is a workspace admin—see the core disk usage rule.
Response
{
"id": "8c4d6e2a-1b3f-4a5c-8d9e-0f1a2b3c4d5e",
"owner": {
"id": 1,
"name": "admin",
"email": "admin@example.com"
},
"name": "Sustained high CPU",
"target": "cpu-usage",
"threshold": 80,
"operator": "gte",
"duration_s": 300,
"recovery_threshold": 70,
"no_data_after_s": 600,
"device": "",
"severity": "critical",
"notify_email": "admins",
"notify_slack_channel": false,
"is_default": false
}
Required scope: alert_rule:create
Example
curl -X POST "https://your-workspace.us1.alpacon.io/api/metrics/alert-rules/" \
-H "Authorization: token=\"alpat-xxxxxxxxxxxxxxxxxx\"" \
-H "Content-Type: application/json" \
-d '{"name": "Sustained high CPU", "target": "cpu-usage", "threshold": 80, "duration_s": 300}'
Get
Get alert rule
Retrieve a specific alert rule by ID.
Request
GET /api/metrics/alert-rules/{rule_id}/
Path parameters
| Parameter | Type | Required | Description |
|---|
rule_id | UUID | Yes | Alert rule ID |
Response
{
"id": "8c4d6e2a-1b3f-4a5c-8d9e-0f1a2b3c4d5e",
"owner": {
"id": 1,
"name": "admin",
"email": "admin@example.com"
},
"name": "Sustained high CPU",
"target": "cpu-usage",
"threshold": 80,
"operator": "gte",
"duration_s": 300,
"recovery_threshold": 70,
"no_data_after_s": 600,
"device": "",
"severity": "critical",
"notify_email": "admins",
"notify_slack_channel": false,
"is_default": false
}
Required scope: alert_rule:read
Example
curl -X GET "https://your-workspace.us1.alpacon.io/api/metrics/alert-rules/8c4d6e2a-1b3f-4a5c-8d9e-0f1a2b3c4d5e/" \
-H "Authorization: token=\"alpat-xxxxxxxxxxxxxxxxxx\""
Recipients
Preview recipients
Preview who an alert rule would notify, as counts—never names—without waiting for it to actually fire.
Request
GET /api/metrics/alert-rules/{rule_id}/recipients/
Path parameters
| Parameter | Type | Required | Description |
|---|
rule_id | UUID | Yes | Alert rule ID |
Response
{
"email": {
"mode": "group_members",
"count": 7,
"reasons": { "admins": 0, "group_members": 6, "owner": 1 }
},
"slack_channel": { "enabled": true, "connected": true },
"event_subscriptions": 2
}
| Field | Description |
|---|
email.mode | The rule’s notify_email value, echoed back |
email.count | How many people this rule’s email destination would reach, counted over the servers you can see that the rule is attached to and can actually fire on. Always 0 for notify_email: "none", and always 0 for a warning- or info-severity rule, whatever notify_email says—only a critical rule mails directly |
email.reasons | How many of those people hold each ground: admins, group_members, owner. The grounds overlap—someone can be a workspace admin and a group member at once—so they don’t have to add up to count |
slack_channel.enabled | The rule’s notify_slack_channel value, echoed back |
slack_channel.connected | Whether the workspace has a Slack installation the alert path can post through. An info-severity rule posts nothing regardless of either value |
event_subscriptions | Live event subscriptions that would receive this rule’s raise and resolve events—those targeting one of the servers counted above, plus workspace-wide subscriptions. A count only; unaffected by notify_email or notify_slack_channel |
No user names, emails, or IDs appear anywhere in the response.
A rule whose servers are all outside what you can see returns 404, the same as a rule you can’t reach at all. A rule that isn’t attached to any server returns zeros rather than 404—that’s the state a rule is in between being created and attached, and 404 there would read as the rule having failed to save.
Required scope: alert_rule:read
Example
curl -X GET "https://your-workspace.us1.alpacon.io/api/metrics/alert-rules/8c4d6e2a-1b3f-4a5c-8d9e-0f1a2b3c4d5e/recipients/" \
-H "Authorization: token=\"alpat-xxxxxxxxxxxxxxxxxx\""
Preview
Preview evaluation
Replay a rule against recent realtime samples to see when it would have raised and cleared, without saving anything. Every raise and resolution is decided by the same evaluator that runs the workspace’s actual rules—this only replays the samples through it.
Request
POST /api/metrics/alert-rules/preview/
Request body
{
"rule": {"target": "cpu-usage", "operator": "gte", "threshold": 80,
"duration_s": 300, "recovery_threshold": 75, "no_data_after_s": null,
"device": "", "severity": "warning",
"notify_email": "all", "notify_slack_channel": true},
"rule_id": "8c4d6e2a-1b3f-4a5c-8d9e-0f1a2b3c4d5e",
"servers": ["3f0e2b1c-5d6a-4e7f-8a9b-0c1d2e3f4a5b"],
"window_s": 3600
}
Parameters
| Field | Type | Required | Description |
|---|
rule | object | Yes | A rule body, same fields as Create alert rule minus name and is_default—sending either is ignored. Always required, even with rule_id set: with rule_id, send only the fields you want to try (the rest come from that rule), or {} to preview the rule unchanged |
rule_id | UUID | No | An existing rule to preview against. Its server overrides apply, and its attached servers become the default for servers |
servers | array of UUID | No, unless rule_id is omitted | Servers to replay over, up to 20 |
window_s | integer | No | How far back to replay, in seconds. Default 86400 (24 hours), minimum 60, maximum 604800 (7 days)—as long as realtime samples are kept |
At least one of rule_id or servers is required.
Two caps beyond the fields above: at most 20 servers, and servers × window_s can’t exceed 5 × 604800 seconds—20 servers caps the window at a little under 1.75 days, and the full 7-day window caps servers at 5. Separately, a replay can’t read or judge more than 300,000 sample rows; a server reporting many disks or interfaces can hit that before the server cap does. With rule_id alone, servers defaults to the servers that rule is attached to; if that default set is larger than 20, the 20-server cap refuses it, so servers must be given explicitly to pick which ones to replay.
The same destination restriction as saving a rule applies here: an application or service token gets metrics_alert_rule_destination_not_allowed if the body sets notify_email or notify_slack_channel.
Response
{
"window": {"start": "2026-09-01T11:00:00Z", "end": "2026-09-01T12:00:00Z"},
"sample_interval_s": 60,
"summary": {"episodes": 1, "would_interrupt": 1, "open_at_end": 0, "longest_s": 240},
"servers": [
{"server": "3f0e2b1c-5d6a-4e7f-8a9b-0c1d2e3f4a5b", "device": "", "samples": 60,
"episodes": [
{"condition": "threshold", "raised_at": "2026-09-01T11:50:00Z",
"resolved_at": "2026-09-01T11:54:00Z", "peak": 91.2, "outlived_hold": true,
"would_interrupt": true}
]}
],
"recipients": {
"email": {"mode": "all", "count": 3,
"reasons": {"admins": 1, "group_members": 2, "owner": 1}},
"slack_channel": {"enabled": true, "connected": false},
"event_subscriptions": 0
},
"not_reproduced": ["tick_jitter", "state_before_window"]
}
| Field | Description |
|---|
window.start / window.end | The replayed window. end is when the request ran, start is window_s seconds before it |
sample_interval_s | The tick the replay judges at, 60 |
summary.episodes | How many episodes fired, across every server and device in the response |
summary.would_interrupt | How many episodes have would_interrupt: true—outlived the roughly two-minute hold and would route to email or Slack under the rule’s severity and destinations, the same test the live evaluator applies. 0 if the rule’s severity and destinations reach neither channel, whatever the episodes did |
summary.open_at_end | Episodes still open at window.end |
summary.longest_s | The longest episode, in seconds; one still open at window.end is measured up to there |
servers[] | One row per (server, device) replayed. A host-wide target (cpu-usage, memory-usage) gets one row with device: ""; a rule scoped to one device gets that device; a device-scoped target left unscoped gets one row per device that sent a sample in the window. A server that’s unmanaged, not commissioned, or excluded by its own override is left out entirely |
servers[].episodes[].condition | threshold or no_data |
servers[].episodes[].raised_at / resolved_at | When the episode opened and closed. resolved_at is null if it’s still open at window.end |
servers[].episodes[].peak | The highest value seen (lowest, for an lte rule) from the oldest sample the raise judged through resolution, or through window.end if still open. null for a no_data episode |
servers[].episodes[].outlived_hold | Whether the episode stayed open past the roughly two-minute hold that gates a live email or Slack post |
servers[].episodes[].would_interrupt | Whether this episode outlived the hold and the rule’s severity and destinations route it to an interrupting channel (email or Slack). Implies outlived_hold: true; still false when the episode outlived the hold but the rule’s severity and destinations wouldn’t send anything |
recipients | Same shape as Preview recipients, computed over the servers actually replayed |
not_reproduced | Which differences between this replay and the live evaluator apply to this response: tick_jitter—a live tick can land a few seconds off the minute this replay assumes; state_before_window—the replay can’t see state from before window.start, so an episode already open when the window begins is missed; maintenance_window_no_data—present when the rule has a no_data_after_s, a maintenance window’s own no-data handling isn’t replayed; core_scope_current—present for the seeded core disk-usage rule, which volume counts as “core” is resolved once as of window.end, not tick by tick |
A rule_id that doesn’t exist, or whose attached servers are all outside what you can see, returns 404. An out-of-reach or unknown entry in servers returns 400 with server_not_found instead. Previewing someone else’s rule this way isn’t refused—the preview stores nothing—and it isn’t recorded in the activity log.
Required scope: alert_rule:read
Example
curl -X POST "https://your-workspace.us1.alpacon.io/api/metrics/alert-rules/preview/" \
-H "Authorization: token=\"alpat-xxxxxxxxxxxxxxxxxx\"" \
-H "Content-Type: application/json" \
-d '{"rule": {"target": "cpu-usage", "threshold": 80, "duration_s": 300}, "servers": ["3f0e2b1c-5d6a-4e7f-8a9b-0c1d2e3f4a5b"]}'
Update
Update alert rule
Update an existing alert rule. Supports partial updates via PATCH.
Request
PATCH /api/metrics/alert-rules/{rule_id}/
Path parameters
| Parameter | Type | Required | Description |
|---|
rule_id | UUID | Yes | Alert rule ID |
Request body
{
"threshold": 85,
"recovery_threshold": 75
}
Parameters
Same fields as Create alert rule, all optional. Changing operator or threshold is refused if it would leave this rule’s own recovery threshold or any server override’s recovery threshold on the wrong side of the new value.
Only certain principals can reach a given rule to update it—see Who can edit or delete a rule. The core disk-usage rule accepts PUT/PATCH only from a workspace admin; everyone else gets metrics_alert_rule_core_admin_only.
Response
Returns the full alert rule object with updated fields (same schema as Get alert rule).
Required scope: alert_rule:update
Example
curl -X PATCH "https://your-workspace.us1.alpacon.io/api/metrics/alert-rules/8c4d6e2a-1b3f-4a5c-8d9e-0f1a2b3c4d5e/" \
-H "Authorization: token=\"alpat-xxxxxxxxxxxxxxxxxx\"" \
-H "Content-Type: application/json" \
-d '{"threshold": 85, "recovery_threshold": 75}'
Delete
Delete alert rule
Remove an alert rule. A default rule (is_default: true) can’t be deleted while it’s still marked as default—unset is_default first.
Only certain principals can reach a given rule to delete it—see Who can edit or delete a rule.
Request
DELETE /api/metrics/alert-rules/{rule_id}/
Path parameters
| Parameter | Type | Required | Description |
|---|
rule_id | UUID | Yes | Alert rule ID |
Response
204 No Content
Required scope: alert_rule:delete
Example
curl -X DELETE "https://your-workspace.us1.alpacon.io/api/metrics/alert-rules/8c4d6e2a-1b3f-4a5c-8d9e-0f1a2b3c4d5e/" \
-H "Authorization: token=\"alpat-xxxxxxxxxxxxxxxxxx\""
The rule’s own values are checked first and still refuse with the non-override codes above. For the two override codes, an update is refused only after every override on the rule has been checked, and field_errors keys each affected override overrides.<server id>.recovery_threshold or overrides.<server id>.duration_s—a server hit by both carries both paths. When the caller can’t see the affected server, the response omits the overrides.* path and carries only non_field_errors with the same code. Overrides changed directly through the rule overrides API keep their own, unchanged error codes.