Alert webhook reference
What Vaicoa sends to an alert target. To set one up, see Send alerts where you work.
Target settings
Section titled “Target settings”Targets are set up in the portal under Alerts → Delivery targets, by an admin. Vaicoa Pro delivers to one target, Vaicoa Team to up to five.
| Field | Target | Notes |
|---|---|---|
| Name | webhook, PagerDuty | up to 60 characters, unique in the team. Shown on the page, in alerts about the target and in the audit log; empty, the address is shown instead |
| Send alerts to this URL | webhook | https:// only, up to 500 characters, no user name or password. Stored encrypted and never shown again: the portal shows only the scheme and host, because for many receivers the path is the credential. To change it, edit the target and paste the full URL again |
| Which alerts | webhook, PagerDuty | Warnings and critical alerts or Critical alerts only, per target. Push notifications are not affected |
| What we send | webhook | Standard payload or Coding-agent brief (see below) |
| Authentication header (optional), Header value | webhook | a header your endpoint expects, for example Authorization. The value is stored encrypted and never shown again. When editing, the field is Replace the header value: empty keeps the stored value. Clearing the name deletes the value. Send a test event sends the header too |
| Service region | PagerDuty | United States or European Union |
| Integration routing key | PagerDuty | from the Events API v2 integration you create in PagerDuty. Stored encrypted and never shown again. Vaicoa cannot issue a new one, because PagerDuty does. When editing, the field is Replace the routing key: paste a new key, or leave it empty to keep the stored one |
Different targets can receive different alerts: for example PagerDuty for the on-call rotation, all warnings to an engineering channel and critical alerts only to an agent endpoint. Remove stops a target; adding it again later does not resend earlier alerts. A plan change never deletes a target: if the new plan allows fewer, the oldest keep delivering and the others are kept, paused, until the plan allows them again or you remove an older one.
The request
Section titled “The request”A webhook target receives one POST request each time an alert starts or is
resolved, and when you use Send a test event.
| Header | Content |
|---|---|
Content-Type | application/json |
User-Agent | CloudViewer-Webhook/1 |
X-CloudViewer-Event | the event value, so you can route without parsing the body |
X-CloudViewer-Delivery | an ID that stays the same across retries; store it if you need idempotency. It equals the body’s id |
X-CloudViewer-Signature | sha256=<hex>, an HMAC-SHA256 of the raw body |
| your own header | only if you set Authentication header (optional) on the target |
Your own header cannot be one Vaicoa sets: Content-Type,
Content-Length, User-Agent, Host, Transfer-Encoding or any name
starting with X-CloudViewer-.
The body is UTF-8 JSON, sent compact (no whitespace) with its keys sorted. The example below is formatted for reading.
The payload
Section titled “The payload”Every event and every receiver gets the same JSON structure:
{ "version": 1, "event": "alert.firing", "id": "9f2c…", "sent_at": "2026-08-16T09:12:33Z", "team": { "id": 42, "name": "Ops", "slug": "ops" }, "alert": { "fingerprint": "a1b2c3…", "rule": "disk_space_critical", "severity": "critical", "status": "firing", "summary": "Disk almost full", "description": "Disk 96% full on web-1 (/var)", "started_at": "2026-08-16T09:11:02Z", "resolved_at": null }, "resource": { "id": "srv_1", "project_id": 10, "server_id": 7 }, "links": { "app": "cloudviewer://alert/a1b2c3…?resource=srv_1&project=10&team=42", "portal": "https://account.vaicoa.com/a/ops/alerts/" }}| Field | Meaning |
|---|---|
version | the schema version, 1. Added fields do not change it; it changes only for a change a receiver could break on |
event | alert.firing, alert.resolved or test |
id | the delivery ID, the same as X-CloudViewer-Delivery |
sent_at | when this request was sent, UTC. It is inside the signed body |
team | the team’s numeric ID, name and slug |
alert.fingerprint | the alert’s identity. With started_at it identifies one alert episode |
alert.rule | the rule key, for example disk_space_critical. See Alert rules |
alert.severity | warning or critical |
alert.status | firing or resolved |
alert.summary, alert.description | the rule’s text for this alert |
alert.started_at | when the alert started, UTC |
alert.resolved_at | null unless the event is alert.resolved |
resource.id | the resource the alert is on, or an empty string |
resource.project_id, resource.server_id | the connected project and monitored server, or null |
links.app | the same deep link the push notification uses. Opening it on a phone shows the alert in the app, where you can mute it |
links.portal | the team’s Alerts page in the portal |
An alert that starts and resolves before it was delivered is sent once, as
alert.resolved, with both timestamps.
The test event has the same shape: alert.rule is webhook_test,
alert.severity is warning, alert.summary is a fixed test text,
resource.id and links.app are empty, and project_id and
server_id are null. Its id is based on the time, so it never collides with
a real alert.
Delivery, timeouts and retries
Section titled “Delivery, timeouts and retries”- One request per target per event, as soon as the alert changes: no grouping and no delay. Each target gets its own copy, so one target being down does not affect the others.
- Warnings and critical alerts only. Informational alerts stay in the app. A target set to Critical alerts only receives no warnings. Muted alerts are never sent.
- Accepted means any
2xxresponse. The response body is read up to 2 KiB and discarded. - Timeout: 5 seconds for the connection and the response.
- Redirects are not followed. A
3xxresponse counts as a failure. - Retry: once a minute. A failed delivery is tried again on the next minute, with the same delivery ID, until it succeeds or the target is switched off. A change older than six hours is no longer sent. After a failure, the remaining events for that target wait for the next minute.
- Switched off after 20 failed attempts in a row, about twenty minutes. A successful delivery resets the count. Switch it back on on the Alerts page, or saving the target, turns it on again.
- At most 20 events per team per minute. The rest follow in the next minute.
- Public https addresses only. A URL must start with
https://and must not contain a user name or password. A URL that resolves to a private or internal address is refused when you save it and again before every request. - A new target does not receive older alerts. Delivery starts with the alerts that change after you add it.
Verifying the signature
Section titled “Verifying the signature”The secret is shown once: when the target is created, and each time you use Issue a new signing secret. Editing a target keeps its secret. Compute the HMAC over the raw request body, before parsing the JSON, and compare in constant time:
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool: expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header)sent_at is part of the signed body, so you can also reject requests older
than, for example, five minutes.
The coding-agent brief
Section titled “The coding-agent brief”A webhook target can send the coding-agent brief instead of the standard
payload: choose Coding-agent brief under What we send. The request is
the same object with the same signature and version, plus one agent
object:
"agent": { "preamble": "You are being handed a production alert from …", "brief_format": "markdown", "brief": "# Incident brief — disk_space_critical on srv_1\n\n…"}| Field | Meaning |
|---|---|
preamble | your team’s Notes for alert hand-offs from Agent Hand-off Notes in the portal, or Vaicoa’s default text when the team has none |
brief_format | markdown |
brief | a short incident brief in Markdown: a title, an Alert section (rule, severity, status, times, summary, description cut to 500 characters), a Resource section and a Links section. A section with nothing in it is left out, not filled with “none” |
The brief is written only from what the standard object contains (the alert,
the resource, the links), so it discloses nothing extra. It is shorter than
the brief the app produces: recent changes, metric trends and cost
information are only available in the app, and the webhook brief leaves them
out. The test event carries the agent object too, so you can see what
your agent will receive. For a chat bridge, use the standard payload. More on
briefs: Hand off to a coding agent.
Slack, Discord and Teams
Section titled “Slack, Discord and Teams”The payload is generic JSON. It is not in Slack’s, Discord’s or Teams’ message format. If you point an incoming webhook URL from one of these services directly at Vaicoa, the message will not display properly, because each service expects its own format.
Put a small bridge in between: a Cloudflare Worker, a Lambda function, an n8n or Make step, or a few lines of code in something you already run. The bridge receives the JSON object, verifies the signature and posts the format your chat service expects.
If you send to your own endpoint (an incident tool, a script, a queue), no bridge is needed. The object above is the complete format.
PagerDuty
Section titled “PagerDuty”A PagerDuty target (Vaicoa Team) sends PagerDuty Events API v2 events to the region you chose:
| Region | Endpoint |
|---|---|
| United States | https://events.pagerduty.com/v2/enqueue |
| European Union | https://events.eu.pagerduty.com/v2/enqueue |
The routing key is in the body, so there is no signature and no
X-CloudViewer-* header. The timeout, retry and switch-off rules are the
same as for a webhook. Any 2xx counts as accepted.
| Vaicoa | PagerDuty event |
|---|---|
alert.firing | event_action: "trigger" |
alert.resolved | event_action: "resolve", with only routing_key, event_action and dedup_key |
| one alert episode | one dedup_key: cv- and 32 hex characters, a hash of the team, the target, the alert’s fingerprint and its start time. Trigger and resolve carry the same key, so the resolve closes the incident the trigger opened |
alert.summary | payload.summary, cut to 1,024 characters (the rule key when there is no summary) |
alert.severity | payload.severity: critical or warning |
resource.id | payload.source; server <id> when there is no resource, the team slug when there is neither |
alert.started_at | payload.timestamp, the alert’s own start, not the send time |
links.app | links: [{"href": …, "text": …}], one link that opens the alert in the app |
links.portal | client_url, with client set to the service’s name |
| rule, fingerprint, IDs, description | payload.custom_details: rule, fingerprint, project_id, server_id, resource_id, description |
An alert that flaps eleven times creates eleven incidents. To merge them, use PagerDuty’s own suppression rules. Two PagerDuty targets open separate incidents.
Send a test event sends a trigger with a fixed summary that says no
alert is firing, severity warning and a dedup key
starting with cv-test-, then a resolve for it. It cannot resolve a real
incident.