Webhooks
Webhooks let you push real-time event notifications from Breeze to any external HTTP endpoint. When a device enrolls, an alert fires, a script completes, or any other tracked event occurs, Breeze sends a signed JSON payload to each webhook configured for that event type. Webhooks belong to an organization or a partner and support custom headers, HMAC-SHA256 signature verification, automatic retries on a fixed schedule, and a full delivery history with per-delivery status tracking.
Creating a Webhook
Section titled “Creating a Webhook”-
Choose the events you want to subscribe to (see Event Types below).
-
Send a POST request with the webhook name, destination URL, signing secret, and event list:
Terminal window POST /webhooksContent-Type: application/json{"name": "PagerDuty Alerts","url": "https://events.pagerduty.com/integration/abc123/enqueue","secret": "whsec_your_signing_secret","events": ["alert.triggered", "alert.acknowledged", "device.offline"],"payloadVersion": 2,"headers": [{ "key": "X-Custom-Source", "value": "breeze-rmm" }]} -
The API validates the URL against the SSRF safety rules (see URL Safety Validation — HTTPS to a publicly routable address on hosted Breeze; self-hosted deployments may also target private addresses), encrypts the secret, and returns the new webhook:
{"id": "d290f1ee-6c54-4b01-90e6-d701748f0851","orgId": "11111111-1111-1111-1111-111111111111","name": "PagerDuty Alerts","url": "https://events.pagerduty.com/integration/abc123/enqueue","events": ["alert.triggered", "alert.acknowledged", "device.offline"],"payloadVersion": 2,"headers": [{ "key": "X-Custom-Source", "value": "breeze-rmm" }],"status": "active","hasSecret": true,"createdAt": "2026-02-18T12:00:00.000Z","updatedAt": "2026-02-18T12:00:00.000Z","lastDeliveryAt": null} -
Send a test delivery to verify connectivity (see Testing a Webhook).
Create request fields
Section titled “Create request fields”| Field | Type | Required | Meaning |
|---|---|---|---|
name |
string | Yes | Webhook name |
url |
string | Yes | Destination URL |
events |
string[] | Yes | Subscribed event types; * receives all events |
payloadVersion |
1 or 2 |
No | Payload version; defaults to 2 |
secret |
string | With HMAC | Signing secret; use a Bearer token header instead when appropriate |
headers |
array of key/value objects | No | Custom request headers |
ownerScope |
organization or partner |
No | Owner of the webhook; defaults to organization |
orgId |
UUID string | For organization webhooks created in partner or system scope | Organization that owns the webhook; omit for partner webhooks |
partnerId |
UUID string | For partner webhooks created in system scope | Partner that owns the webhook; partner sessions use their authenticated partner |
Organization Scoping
Section titled “Organization Scoping”Webhooks belong to one organization or to a partner. Organization webhooks receive that organization’s events. Organization sessions use their authenticated organization; partner and system sessions supply orgId when creating an organization webhook.
Partner administrators with access to all of their partner’s organizations can choose All organizations when creating a webhook (ownerScope: "partner", without orgId). System sessions also supply partnerId. Ownership cannot be changed after creation. Organization users and partner users with access to selected organizations cannot see partner webhooks or their delivery history.
Partner webhooks receive events from every live organization belonging to their partner. Holding organizations (the unassigned pool), quick-support organizations, and suspended or deleted organizations are excluded. The delivery envelope’s orgId identifies the event’s organization. Receivers can filter on that value; this release has no per-organization subscription filter. If an organization leaves the partner, its queued deliveries are stopped before sending.
New partner webhooks default to payload v2. Their v2 alert payloads include orgExternalIds, which lets receivers map the organization to external systems. Test sends from partner webhooks carry orgId: null and appear as Test in delivery history.
An organization’s tenant export includes delivery metadata for its own events sent through partner webhooks. The stored payload and receiver response text are excluded, as are the partner’s webhook configuration and secrets.
Event Types
Section titled “Event Types”Webhooks subscribe to events emitted by the Breeze event bus. Pass one or more event type strings in the events array when creating or updating a webhook. Use * to receive every event. The API rejects an event type that Breeze never publishes with a 400, so a typo cannot save a webhook that silently never fires. The legacy name alert.created is accepted and stored as alert.triggered.
Device Events
Section titled “Device Events”| Event | Description |
|---|---|
device.enrolled |
An agent has completed enrollment (first enrollment or re-enrollment; payload reenrollment distinguishes them) |
device.online |
A device has come online (heartbeat received) |
device.offline |
A device has stopped sending heartbeats |
device.updated |
Device metadata or configuration has changed |
device.reinstalled |
A reinstalled machine was reattached to its existing device record: the record keeps its id, org, site and history, a new install takes it over, and the install incarnation advances. Payload: deviceId, sourceDeviceId, orgId, sourceOrgId, siteId, installIncarnation, previousIncarnation, evidenceLevel, actorUserId, reattachmentId |
Alert Events
Section titled “Alert Events”| Event | Description |
|---|---|
alert.triggered |
A monitoring rule has fired a new alert |
alert.acknowledged |
An alert has been acknowledged by a user or service principal |
alert.resolved |
An alert condition has cleared |
alert.suppressed |
An alert has been suppressed: from the dashboard, POST /alerts/:id/suppress, the bulk action (POST /alerts/bulk), or Breeze AI |
alert.escalated |
An escalation policy step fired for an alert that is still active. One event per alert per step occurrence (each renotify repeat is its own occurrence), however many channels and users the step pages |
Script Events
Section titled “Script Events”| Event | Description |
|---|---|
script.started |
Script execution has begun on a device |
script.completed |
Script execution finished successfully |
script.failed |
Script execution failed or timed out (a cancelled run emits neither) |
Automation Events
Section titled “Automation Events”| Event | Description |
|---|---|
automation.started |
An automation workflow has begun |
automation.completed |
An automation workflow completed successfully |
automation.failed |
An automation workflow failed |
Policy Events
Section titled “Policy Events”| Event | Description |
|---|---|
policy.evaluated |
A configuration policy has been evaluated against a device |
policy.violation |
A device is non-compliant with a policy |
policy.compliant |
A device has returned to compliance |
Patch Events
Section titled “Patch Events”| Event | Description |
|---|---|
patch.available |
New patches are available for a device |
patch.approved |
A patch has been approved for installation |
patch.installed |
A patch was installed successfully |
patch.failed |
A patch installation failed |
patch.rollback |
A patch has been rolled back |
Ticket Events
Section titled “Ticket Events”Ticket events are published from the ticketing transactional outbox with id-only payloads: identifiers, enum labels and field names, never a subject, description, resolution note or comment body. Read the full record through the API (staff JWT, or the Partner API tickets:read feed) when you need it. Rely only on the fields listed below: a payload can also carry other internal identifiers, which are not part of this contract.
| Event | Payload (besides ticketId) |
Description |
|---|---|---|
ticket.created |
internalNumber, source, assigneeId, actorPrincipalId |
A ticket was opened (from the portal, email, an alert, the UI, the Partner API or AI). actorPrincipalId is the service principal that opened it (null otherwise) |
ticket.commented |
commentId, isPublic, originPrincipalKind, originPrincipalId |
A comment, note or feed entry was added. originPrincipalKind and originPrincipalId are the comment’s stored origin, the same values the Partner API feed returns for it: service_principal with the principal’s id, ai_agent with the agent’s id, user for people, and system for Breeze’s own system notes, such as caller verification (originPrincipalId is null for the last two). A comment Breeze writes on a customer’s behalf is user by design: a reply that arrives by email (or a public email linked from the Outlook add-in) and the reopen that reply triggers. A mirroring integration skips exactly the comments it wrote by matching service_principal and its own principal id |
ticket.status_changed |
from, to, statusId |
The core status changed (statusId is the partner’s custom status row, when one was used) |
ticket.updated |
changed (field names) |
Fields were edited — subject, description, priority, category, due date, device, tags, requester |
ticket.assigned |
assigneeId (null when unassigned), actorPrincipalId |
The assignee changed. actorPrincipalId is the service principal that made the change (null for people and the system), so a mirroring integration can skip its own assignments |
ticket.merged |
sourceTicketId, targetTicketId, sourceInternalNumber, targetInternalNumber |
A ticket was merged into another. Sent once, on the surviving ticket (ticketId = targetTicketId). The source stays as a read-only record: a Partner API read of it returns mergedIntoTicketId and mergedAt. The source also gets a ticket.status_changed to closed when it was not already closed, and the survivor a ticket.updated with changed: ["merge"]. Comments moved by the merge are not re-sent as ticket.commented |
ticket.sla_breached |
assigneeId |
A response or resolution SLA target was missed |
Migration Window Events
Section titled “Migration Window Events”A ticket migration window lets a partner admin import ticket history from another system without notifying anyone or firing per-ticket events. Nothing the import writes produces a ticket event. When the window closes, each organization in it gets one summary event.
| Event | Payload | Description |
|---|---|---|
migration.window_closed |
windowId, partnerId, sourceLabel, openedAt, endsAt, closedAt, closedReason, ticketsImported, commentsImported, attachmentsImported |
A migration window closed. Sent once per organization in the window, with that organization’s counts. attachmentsImported counts the files attached to imported comments. closedReason is expired, closed_by_admin, principal_inactive or opener_inactive. The event id is <windowId>:<orgId>, so a retried delivery is recognizable |
Integration Events
Section titled “Integration Events”Integration events report the health of an organization’s own connections: an integration, a sync or an OAuth grant. A health incident opens when a connection breaks and resolves when it recovers or is retired.
| Event | Payload | Description |
|---|---|---|
integration.health_opened |
incidentId, orgId, sourceKind, sourceId, condition, severity, fixHref, openedAt, resolvedAt, resolution |
A connection health incident opened. resolvedAt and resolution are null |
integration.health_resolved |
incidentId, orgId, sourceKind, sourceId, condition, severity, fixHref, openedAt, resolvedAt, resolution |
A connection health incident resolved |
| Field | Type | Description |
|---|---|---|
incidentId |
string (uuid) | The incident. Both events for one incident carry the same value |
orgId |
string (uuid) | The organization that owns the connection |
sourceKind |
string | The kind of connection, for example m365_connection, dns_filter or webhook |
sourceId |
string or null |
The connection’s id. null for oauth_grant, because that id embeds a user id |
condition |
string | A fixed code for what is wrong, for example sync_failed or reauth_required |
severity |
string | permanent (needs someone to act) or transient (may clear on its own) |
fixHref |
string | A relative path in the Breeze web app where the connection can be repaired |
openedAt |
string (ISO timestamp) | When the incident opened |
resolvedAt |
string (ISO timestamp) or null |
When the incident resolved. null while it is open |
resolution |
string or null |
recovered or retired. null while the incident is open |
- No free text. The payload never contains provider error text or the connection’s display name. Use
fixHrefand the API to look up more. - Organization connections only. Only incidents about an organization’s own connections emit events. Partner-wide connections do not.
- Level-triggered. Events follow the state seen at each health check, about every 15 minutes. A connection that breaks and recovers between two checks may emit nothing. A consumer can also receive an
integration.health_resolvedevent for an incident it never saw open, for example an incident that existed before the feature shipped. - Stable event ids. The event id is stable for a given incident and state, so retried deliveries are deduplicated.
- No self-delivery. An incident about a webhook is never delivered to that same webhook.
Other Events
Section titled “Other Events”| Event | Description |
|---|---|
security.score_changed |
A device’s security posture score has changed |
remote.session.started |
A remote access session has begun |
remote.session.ended |
A remote access session has ended |
user.login |
A user logged in |
user.logout |
A user logged out |
session.login |
A device user session started |
session.logout |
A device user session ended |
Delivery Mechanism
Section titled “Delivery Mechanism”When an event is emitted on the Breeze event bus, the webhook delivery system routes it to subscribed active webhooks owned by that organization and, for live organizations, its partner. Each delivery follows this sequence:
-
The event bus fires the event. The webhook subscriber matches the event type against eligible organization and partner webhooks.
-
A delivery record is created in the
webhook_deliveriestable with statuspendingandattemptsset to0. -
A BullMQ job carrying only the delivery and webhook ids, generation, and attempt count is queued on
webhook-delivery. -
The worker claims the delivery row as
retrying, validates the destination URL (including DNS resolution to block SSRF), and sends an HTTP POST with the signed payload. -
A
2xxresponse marks the rowdelivered. A failure marks itscheduledwith the next attempt time, orfailedafter the last attempt.
Payload Format
Section titled “Payload Format”The original v1 JSON POST body has this structure. See Payload versions for the v2 envelope.
{ "id": "event-uuid", "type": "device.offline", "timestamp": "2026-02-18T12:34:56.789Z", "orgId": "11111111-1111-1111-1111-111111111111", "data": { "deviceId": "abc123", "hostname": "WORKSTATION-01", "lastSeen": "2026-02-18T12:30:00.000Z" }}| Field | Description |
|---|---|
id |
Unique event ID (UUID) |
type |
The event type string (e.g., alert.triggered) |
timestamp |
ISO 8601 timestamp of when the event was created |
orgId |
The organization the event belongs to; null for partner-webhook test sends |
data |
Event-specific payload; contents vary by event type |
Payload versions
Section titled “Payload versions”Each webhook has a payloadVersion: 1 or 2. Webhooks created before this release stay on 1; their payload is unchanged byte for byte. New webhooks default to 2. Webhooks created through the API without payloadVersion also default to 2.
Choose the version in the edit form, or send PATCH /webhooks/:id with { "payloadVersion": 2 }. You can switch back to 1. Each delivery keeps the payload version it was recorded at, including manual replays and legacy rows. Editing the webhook stops its scheduled retries when they next come due.
The v2 envelope is { id, type, timestamp, orgId, schemaVersion: 2, replayOf, data }, in that order. The v1 envelope has no schemaVersion or replayOf. For non-alert events, v2 changes only the envelope; data is the same as v1. A test delivery (POST /webhooks/:id/test) uses the webhook’s version with sample data; it does not enrich that sample.
Alert event data
Section titled “Alert event data”Only alert.triggered, alert.acknowledged, alert.resolved, alert.suppressed, and alert.escalated use the v2 alert data below. Other event types keep their original data.
v1: alert.triggered
Section titled “v1: alert.triggered”| Field | Type | Meaning |
|---|---|---|
alertId |
string | Alert id |
ruleId |
string or null | Rule id; null for rule-less or sourced alerts |
deviceId |
string | Device id |
severity |
string | Alert severity |
title |
string | Alert title |
message |
string | Alert message |
monitorId |
string or null | Always present on alert-service paths; null when there is no monitor |
kind |
string or null | Always present on alert-service paths; null when there is no monitor |
episodeId |
string or null, when present | Episode id for the subject-key path |
subjectKey |
string or null, when present | Subject identity; null on the ordinary rule-alert path |
responsesOwner |
boolean, when present | Whether this alert owns the response actions |
source |
string, when present | Source of a rule-less or sourced alert |
| Producer-specific extra keys | varies | Some producers add extra keys such as networkChangeEventId, vendor, connectionId, provider, detectionId, requiresHuman, automationId, runId, flowRunId, and anomalyId |
v1: alert.acknowledged
Section titled “v1: alert.acknowledged”| Field | Type | Meaning |
|---|---|---|
alertId |
string | Alert id |
ruleId |
string or null | Rule id |
deviceId |
string | Device id |
acknowledgedBy |
string or null | Id of the acknowledging user; null for a service principal |
acknowledgedByPrincipalId |
string (optional) | Partner API service principal id; present only for a principal acknowledgment |
v1: alert.resolved
Section titled “v1: alert.resolved”Several paths publish this event: the alert service, REST resolve, bulk actions, mobile, correlation-group actions, Breeze AI, warranty checks, and the subject-alert outbox. Fields vary by producer.
| Field | Type | Meaning |
|---|---|---|
alertId |
string | Alert id; always present |
ruleId |
string or null, when present | Rule id; omitted by some producers |
deviceId |
string | Device id; always present |
resolutionNote |
string or null, when present | Resolution note; omitted by some producers |
resolvedAt |
ISO 8601 string | Resolution time; always present |
resolvedBy |
string or null | Resolving user id; always present, null for system or service principal resolutions |
resolvedByPrincipalId |
string (optional) | Partner API service principal id; present only for a principal resolution, which counts as a person’s resolve and starts no automatic AI verdict run |
triggeredAt |
ISO 8601 string | Trigger time; always present |
resolutionReason |
string or null, when present | Resolution reason; alert-service paths only |
subjectKey |
string, when present | Subject identity; subject recoveries only |
v1: alert.suppressed
Section titled “v1: alert.suppressed”Every suppress path publishes this event with the same data: POST /alerts/:id/suppress, the bulk action, and Breeze AI. It is published only when the suppression was written; a request that lost a race to a resolve or dismiss publishes nothing. Re-suppressing an already suppressed alert (to change its end time) publishes again.
| Field | Type | Meaning |
|---|---|---|
alertId |
string | Alert id |
ruleId |
string or null | Rule id |
deviceId |
string | Device id |
suppressedBy |
string | Suppressing user id |
suppressedUntil |
ISO 8601 string or null | Suppression end time; null for indefinite suppression |
durationHours |
number or null | Suppression duration in hours: the duration Breeze AI was given, or the time from the request to suppressedUntil (2 decimals) for an end-time suppression. Null for indefinite suppression |
v1: alert.escalated
Section titled “v1: alert.escalated”Published when an escalation policy step fires. The step fires only while the alert is active: if the alert was acknowledged, suppressed, resolved, or dismissed before the step came due, no event is sent. A step none of whose channels or users is still valid sends nothing and publishes nothing. A retried publish reuses the event id, so deduplicate on it.
| Field | Type | Meaning |
|---|---|---|
alertId |
string | Alert id |
ruleId |
string or null | Rule id |
deviceId |
string | Device id |
escalationPolicyId |
string | Escalation policy id |
escalationStep |
number | Occurrence id: policyStep + 10 * repeat |
policyStep |
number | Position of the step in the policy, 1 to 10 |
repeat |
number | Renotify repeat, 0 for the step’s first firing |
v2: all five alert events
Section titled “v2: all five alert events”Every key below is always present. A value that does not apply or is unavailable is null. The status comes from the event type, even if the alert has since changed. Valid payload values take precedence over the current alert row.
externalTickets is also captured when the delivery is first enriched. A
reference written afterward is absent from that stored payload, and retries do
not refresh it. For current state, call
GET /api/v1/partner-api/alerts/<alertId>/external-tickets with alerts:read
(or the staff GET /api/v1/alerts/<alertId>/external-tickets route). V1 payloads
are unchanged.
Visibility follows webhook ownership. Partner-owned webhooks receive every partner-principal reference on the alert plus mappings from PSA connections owned by that partner or by the alert’s organization. Org-owned webhooks receive only mappings from PSA connections owned by the alert’s organization with no partner owner. They never receive partner-principal references or partner-owned PSA connection mappings. Org-scoped users of the staff GET route receive the same restricted view as an org-owned webhook.
Hostname, site, and organization name are read when the delivery is first recorded, not at the moment the event happened. Retries resend the stored payload unchanged. enrichmentIncomplete: true means the alert row could not be read (it was not yet committed, the id was invalid, or the enrichment query failed), so enriched fields may be null; look the alert up by alertId. A missing device or site leaves its fields null without setting the flag.
Alerts can belong to a device, a topology site, or a SaaS backup workload.
Workload alerts keep deviceId: null; use subjectName for display, for example
Microsoft 365 mail backup · Example customer. Organization-owned webhook
labels honor the connection’s portal provider-name setting. The owner display
fields below are additive; existing event fields keep their previous meanings,
and an event’s owner and ownerKey extras remain available in details.
| Field | Type | Meaning |
|---|---|---|
alertId |
string or null | Alert id |
ruleId |
string or null | Rule id |
monitorId |
string or null | Monitor id |
deviceId |
string or null | Device id |
owner |
object or null | { kind, ref }; kind is device, topology_site, or backup_workload, and ref is the owner id or null when unavailable |
ownerKind |
string or null | Owner kind; null when neither the row nor event identifies the owner |
ownerLabel |
string or null | Display label for the owner, such as Microsoft 365 mail backup |
subjectName |
string or null | Display subject, including the customer name for a backup workload |
episodeId |
string or null | Alert episode id |
status |
active, acknowledged, resolved, or suppressed |
State represented by this event; alert.triggered and alert.escalated mean active |
severity |
critical, high, medium, low, info, or null |
Alert severity |
title |
string or null | Alert title |
message |
string or null | Alert message |
triggeredAt |
ISO 8601 string or null | Trigger time |
acknowledgedAt |
ISO 8601 string or null | Acknowledgment time |
resolvedAt |
ISO 8601 string or null | Resolution time |
deviceHostname |
string or null | Device hostname |
siteId |
string or null | Topology alert’s owning site, otherwise the device’s site |
siteName |
string or null | Site name |
orgName |
string or null | Organization name |
alertUrl |
string | Dashboard link to the alert, or /alerts when the alert id is unavailable; relative when no dashboard base URL is configured |
externalTickets |
array of objects | References and PSA mappings permitted for the webhook owner at enrichment time (org-owned webhooks receive only org-owned PSA mappings). Each object has exactly source (partner_api or psa), externalId, nullable externalUrl, and nullable externalNumber; empty when none exist |
acknowledgedByPrincipalId |
string or null | Partner API service principal that acknowledged the alert; null when absent |
resolvedByPrincipalId |
string or null | Partner API service principal that resolved the alert; null when absent. A principal resolution counts as a person’s resolve and starts no automatic AI verdict run |
enrichmentIncomplete |
boolean | The alert row could not be read or the enrichment query failed; missing device or site alone does not set it |
subjectKey |
string or null | Alert subject identity |
resolutionNote |
string or null | Resolution note |
resolvedBy |
string or null | Resolving user id |
resolutionReason |
string or null | Resolution reason |
acknowledgedBy |
string or null | Acknowledging user id |
suppressedBy |
string or null | Suppressing user id from the event |
suppressedUntil |
ISO 8601 string or null | Suppression end time |
responsesOwner |
boolean or null | Whether this alert owns response actions, when supplied by the event |
kind |
string or null | Monitor or producer kind from the event |
source |
string or null | Alert source from the event |
details |
object | Producer-specific extras, such as durationHours or networkChangeEventId. These keys are not a stable contract; {} when there are none |
A complete v2 alert.triggered example:
{ "id": "60ecc32d-4bde-4716-930d-a433a86836aa", "type": "alert.triggered", "timestamp": "2027-01-12T12:34:56.000Z", "orgId": "11111111-1111-4111-8111-111111111111", "schemaVersion": 2, "replayOf": null, "data": { "alertId": "22222222-2222-4222-8222-222222222222", "ruleId": "33333333-3333-4333-8333-333333333333", "monitorId": "44444444-4444-4444-8444-444444444444", "deviceId": "55555555-5555-4555-8555-555555555555", "owner": { "kind": "device", "ref": "55555555-5555-4555-8555-555555555555" }, "ownerKind": "device", "ownerLabel": "WORKSTATION-01", "subjectName": "WORKSTATION-01", "episodeId": null, "status": "active", "severity": "high", "title": "Low disk space", "message": "Available disk space is below 10%.", "triggeredAt": "2027-01-12T12:34:56.000Z", "acknowledgedAt": null, "resolvedAt": null, "deviceHostname": "WORKSTATION-01", "siteId": "66666666-6666-4666-8666-666666666666", "siteName": "Main office", "orgName": "Example Organization", "alertUrl": "https://app.example.com/alerts/22222222-2222-4222-8222-222222222222", "externalTickets": [], "acknowledgedByPrincipalId": null, "resolvedByPrincipalId": null, "enrichmentIncomplete": false, "subjectKey": null, "resolutionNote": null, "resolvedBy": null, "resolutionReason": null, "acknowledgedBy": null, "suppressedBy": null, "suppressedUntil": null, "responsesOwner": true, "kind": "disk", "source": null, "details": {} }}Replays and deduplication
Section titled “Replays and deduplication”A manual retry creates a new event with a new id. It carries X-Breeze-Replay-Of: <original event id> in either version, and replayOf in the v2 body. A replay of a replay keeps the original event id as its root.
Deduplicate transport on the original event id: replayOf ?? id for v2, or X-Breeze-Replay-Of falling back to id for v1. Deduplicate tickets on alertId plus your own ticket state, not on alertId + type: a suppressed alert can return to active and be acknowledged again.
Request Headers
Section titled “Request Headers”Deliveries use these headers; signature and replay headers apply as noted:
| Header | Description |
|---|---|
Content-Type |
application/json |
User-Agent |
Breeze-Webhooks/1.0 |
X-Breeze-Delivery-Id |
Unique ID for this delivery attempt |
X-Breeze-Event-Id |
The event ID from the payload |
X-Breeze-Event-Type |
The event type string |
X-Breeze-Timestamp |
Unix timestamp (milliseconds) of the delivery attempt |
X-Breeze-Signature |
HMAC-SHA256 signature (only if a secret is configured) |
X-Breeze-Signature-Timestamp |
Timestamp used in the signature computation |
X-Breeze-Replay-Of |
Original event id on a manual retry, in either payload version; absent on original deliveries |
Any custom headers configured on the webhook are merged into the request after the standard headers.
Retry Logic
Section titled “Retry Logic”Every non-2xx response, timeout, or transport error is retried on this fixed schedule. Each request has a 30-second timeout.
| Attempt | Delay after the previous attempt |
|---|---|
| 1 | Immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
Each delay has ±20% jitter. The base schedule spans about 21 hours from the first attempt to the last; a seventh failure leaves the delivery failed. The per-webhook retry_policy is ignored.
Retry state lives in Postgres, with BullMQ delayed jobs waking each attempt. A wake-up lost to a failed enqueue or to Redis data loss is re-driven by the recovery sweep once the delivery is more than 15 minutes overdue. Each attempt is claimed before sending, so two workers cannot send the same attempt. If a worker stops mid-request, recovery marks the delivery failed with an unknown outcome instead of re-sending a potentially delivered request.
Editing or disabling a webhook stops its scheduled retries; each is marked failed (superseded_by_edit:) when it next comes due, including retries re-driven by the recovery sweep. Replay it manually if needed. A delivery keeps the payload version it was recorded at; this also applies to manual replays and legacy rows.
The Redis dead-letter queue is retired. On first start after upgrading, Breeze drains the old Redis delivery list and DLQ: pending rows and old in-memory retries are re-driven; entries whose delivery no longer exists or already finished are dropped and counted. DLQ entries become failed deliveries available for manual replay when their webhook still exists. Entries that fail to process stay in Redis and are retried by the recovery sweep.
Bearer Token Authentication
Section titled “Bearer Token Authentication”If your endpoint expects a static token rather than a signature, authenticate with a bearer token instead of a signing secret. In the dashboard, choose Bearer token under Authentication. Through the API, send the token as an Authorization custom header and omit secret:
{ "name": "Ticketing intake", "url": "https://intake.example.com/breeze", "events": ["ticket.created"], "headers": [ { "key": "Authorization", "value": "Bearer your_token" } ]}The token is stored encrypted with the other custom headers and is never returned: GET responses show the header with a redacted value and report "authType": "bearer" ("hmac" for a signing secret, "none" for neither). A webhook needs either a secret or an Authorization: Bearer <token> header; other Authorization schemes (such as Basic) can be added as custom headers alongside a signing secret. To switch an existing webhook from HMAC to bearer, send "secret": null together with the Authorization header in the same PATCH. To keep a stored token while you edit other headers, send its value back as ********.
Signature Verification
Section titled “Signature Verification”When a webhook has a signing secret, every delivery includes an HMAC-SHA256 signature in the X-Breeze-Signature header. Use this to verify that the payload was sent by Breeze and has not been tampered with.
How the Signature Is Computed
Section titled “How the Signature Is Computed”The signature input is the concatenation of the timestamp and the raw JSON payload body, separated by a dot:
signature_input = timestamp + "." + payload_bodysignature = HMAC-SHA256(secret, signature_input)The X-Breeze-Signature header value is prefixed with sha256=:
X-Breeze-Signature: sha256=a1b2c3d4e5f6...The X-Breeze-Signature-Timestamp header contains the same timestamp value used in the signature computation. The signature covers the raw body for both payload versions.
Verification Examples
Section titled “Verification Examples”import { createHmac, timingSafeEqual } from 'crypto';
function verifyWebhookSignature(payload, secret, signatureHeader, timestampHeader) { const expectedSig = signatureHeader.replace('sha256=', ''); const signatureInput = `${timestampHeader}.${payload}`; const computedSig = createHmac('sha256', secret) .update(signatureInput) .digest('hex');
return timingSafeEqual( Buffer.from(expectedSig, 'hex'), Buffer.from(computedSig, 'hex') );}import hmacimport hashlib
def verify_webhook_signature(payload: bytes, secret: str, signature_header: str, timestamp_header: str) -> bool: expected_sig = signature_header.removeprefix("sha256=") signature_input = f"{timestamp_header}.{payload.decode('utf-8')}" computed_sig = hmac.new( secret.encode('utf-8'), signature_input.encode('utf-8'), hashlib.sha256 ).hexdigest()
return hmac.compare_digest(expected_sig, computed_sig)Example: alert to ConnectWise Manage through middleware
Section titled “Example: alert to ConnectWise Manage through middleware”This outline uses a low-code automation platform such as Rewst between Breeze and ConnectWise Manage. Adapt the fields and priority mapping to your Manage configuration.
- Create a v2 webhook subscribed to
alert.triggered,alert.acknowledged, andalert.resolved. Point it at the middleware’s incoming webhook URL. - Verify the request before processing it. Use the HMAC recipe above with
X-Breeze-Signature-Timestampand the raw body. If the platform can only check a static header, use Breeze’s Bearer token option and validate theAuthorizationheader instead. - Deduplicate received requests on
replayOf ?? id. Store the key after processing succeeds so a failed middleware attempt can be retried. - On
alert.triggered, mapdata.severityto your Manage priority ids. For example, mapcriticalandhighto your urgent queue, and send null severity for review. Usedata.orgNameto look up the company,data.deviceHostnameanddata.titlein the ticket summary, anddata.alertUrlin the description. Keep the lookup explicit when organization names are ambiguous or enrichment is incomplete. - Store an
alertId→ ticket id mapping. Onalert.acknowledged, update the mapped ticket according to your workflow. Use the alert id and current ticket state to decide whether to create or update; repeated alert states must not create duplicate tickets. - On
alert.resolved, close the mapped ticket with your configured Manage closed status. If no mapping exists, send the event for review rather than creating a new closed ticket. - Grant a dedicated principal
alerts:write. When the Manage ticket closes, callPOST /api/v1/partner-api/alerts/<alertId>/resolvewith an optionalnote. Ignore webhook events whoseacknowledgedByPrincipalIdorresolvedByPrincipalIdis your own principal id so the write-back does not loop.
Delivery History
Section titled “Delivery History”Every webhook maintains a paginated delivery history. Each delivery record captures the event type, payload, response details, and retry state. The list returns status and timing only; the event payload and the receiver’s response body are not included. Reading the history requires webhooks:read.
From v0.124.0, delivered and failed records are kept for 90 days by default and then removed by a nightly job; records that are still pending, scheduled or retrying are never removed. Self-hosters can change the window with the webhook delivery retention variables in Environment Variables. A webhook whose delivery ends failed after its last attempt, with no later success, opens a connection-health incident on Integrations. For a dead endpoint, that is about 21 hours after the first failed attempt, rather than minutes. Decrypt failures open an incident immediately. See Connection Health Checks.
Listing Deliveries
Section titled “Listing Deliveries”GET /webhooks/:id/deliveries?page=1&limit=50&status=failedAll query parameters are optional. Results are ordered by most recent first.
| Parameter | Type | Description |
|---|---|---|
page |
integer | Page number (default: 1) |
limit |
integer | Results per page (default: 50, max: 100) |
status |
string | Filter by delivery status: pending, scheduled, delivered, failed, retrying |
Response:
{ "data": [ { "id": "delivery-uuid", "webhookId": "webhook-uuid", "orgId": "org-uuid", "status": "delivered", "event": "alert.triggered", "eventId": "event-uuid", "responseStatus": 200, "attempt": 1, "nextAttemptAt": null, "createdAt": "2026-02-18T12:34:56.000Z", "deliveredAt": "2026-02-18T12:34:57.123Z" } ], "pagination": { "page": 1, "limit": 50, "total": 142 }}Delivery Statistics
Section titled “Delivery Statistics”When you fetch a single webhook via GET /webhooks/:id, the response includes aggregated delivery statistics:
{ "id": "webhook-uuid", "name": "PagerDuty Alerts", "payloadVersion": 2, "status": "active", "deliveryStats": { "total": 1284, "pending": 2, "delivered": 1250, "failed": 30, "retrying": 2, "scheduled": 0, "lastDeliveredAt": "2026-02-18T12:34:57.123Z" }}Retrying a Failed Delivery
Section titled “Retrying a Failed Delivery”You can manually retry any delivery that has a failed status. The retry creates a new delivery record with the same event type and payload, and queues it for immediate processing. The endpoint returns 202 Accepted even when queuing fails: the delivery stays pending, and the recovery sweep sends it within about 15–20 minutes.
POST /webhooks/:id/retry/:deliveryIdResponse (202 Accepted):
{ "message": "Delivery retry queued", "delivery": { "id": "new-delivery-uuid", "status": "pending", "attempt": 0 }}Testing a Webhook
Section titled “Testing a Webhook”Send a test delivery to verify that your endpoint is reachable and correctly processing payloads:
POST /webhooks/:id/testContent-Type: application/json
{ "event": "webhook.test", "payload": { "message": "Hello from Breeze RMM" }}Both event and payload are optional. If omitted, the test uses event type webhook.test and a default payload containing a message, timestamp, and webhook ID.
Response (202 Accepted):
{ "message": "Test delivery queued", "delivery": { "id": "delivery-uuid", "webhookId": "webhook-uuid", "status": "pending", "event": "webhook.test", "attempt": 0 }}The test endpoint returns 202 Accepted even when queuing fails: the delivery stays pending, and the recovery sweep sends it within about 15–20 minutes. The AI test_webhook tool likewise reports the delivery as accepted when queuing fails. A test delivery uses the same HMAC signing, URL validation, and delivery pipeline as real events; a failing endpoint follows the full retry schedule.
Updating a Webhook
Section titled “Updating a Webhook”Update any combination of name, URL, secret, events, headers, status, or payload version:
PATCH /webhooks/:idContent-Type: application/json
{ "events": ["device.enrolled", "device.offline", "alert.triggered"], "payloadVersion": 2, "status": "paused"}| Request field | Type | Meaning |
|---|---|---|
name |
string | Webhook name |
url |
string | Destination URL |
events |
string[] | Replacement subscription list |
payloadVersion |
1 or 2 |
Version for future deliveries; stored deliveries keep their recorded version |
secret |
string or null | Signing secret; null removes it when switching to Bearer authentication |
headers |
array of key/value objects | Replacement custom headers |
status |
string | One of the values below |
All update fields are optional.
| Status Value | Description |
|---|---|
active |
Webhook is enabled and will receive deliveries |
paused |
Webhook is temporarily disabled; no deliveries are sent |
failed |
Webhook has been marked as failed (can be set manually or by the system) |
Deleting a Webhook
Section titled “Deleting a Webhook”DELETE /webhooks/:idURL Safety Validation
Section titled “URL Safety Validation”Breeze validates webhook URLs at both creation time and delivery time to prevent SSRF (Server-Side Request Forgery) attacks.
At creation and update time, the API performs synchronous validation including DNS resolution. On hosted Breeze:
- URL must use HTTPS
- Hostname must not be
localhost,*.localhost, or*.local - IP addresses must not be private, loopback, or link-local (covers RFC 1918, RFC 6598, RFC 5737, and IPv6 equivalents)
- DNS resolution is performed to catch hostnames that resolve to blocked address ranges
Self-hosted deployments (IS_HOSTED=false) may point webhooks at private RFC 1918/ULA addresses on their own network — an on-prem SIEM or ticketing system, for example — and plain http:// is permitted only when the target resolves to such a private address. Public destinations still require HTTPS, and cloud-metadata, loopback, and link-local addresses are blocked in every configuration.
At delivery time, the worker re-validates the URL before every delivery attempt. If the URL has become unsafe (e.g., DNS now resolves to a private IP), the delivery fails immediately without making an HTTP request.
API Reference
Section titled “API Reference”| Method | Path | Description |
|---|---|---|
| GET | /webhooks |
List accessible webhooks (paginated, filterable by status, ownerScope, and orgId; an orgId filter returns only that organization’s own webhooks) |
| POST | /webhooks |
Create a new webhook |
| GET | /webhooks/:id |
Get webhook details including delivery statistics |
| PATCH | /webhooks/:id |
Update webhook name, URL, secret, events, headers, status, or payload version |
| DELETE | /webhooks/:id |
Delete a webhook and all its delivery records |
| GET | /webhooks/:id/deliveries |
List delivery history (paginated, filterable by status) |
| POST | /webhooks/:id/test |
Send a test delivery |
| POST | /webhooks/:id/retry/:deliveryId |
Retry a failed delivery |
All webhook routes require authentication and are scoped to organization, partner, or system roles. They also require a permission:
- Reading (
GET /webhooks,GET /webhooks/:id,GET /webhooks/:id/deliveries) requireswebhooks:read. The AI assistant’squery_webhookstool requires the same permission. - Managing (create, update, delete, test, retry) requires
organizations:write.
webhooks:read is granted by default to Org Admin, Org Technician and Partner Technician; Partner Admin holds it through *:*. In the dashboard, a user without webhooks:read does not see the Webhooks tab under Integrations, and a user without organizations:write sees webhooks and their delivery history without the create, edit, delete, test, pause/resume or retry controls. See Users & Roles to grant either permission to a role.
Database Schema
Section titled “Database Schema”webhooks
Section titled “webhooks”| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key |
org_id |
UUID | Organization this webhook belongs to |
name |
varchar(255) | Display name |
url |
text | Destination URL (HTTPS; self-hosted deployments may use HTTP to private addresses); encrypted at rest using AES. GET responses strip any credentials embedded in the URL (HTTP basic-auth userinfo and signed query tokens) before returning it. |
secret |
text | Encrypted HMAC signing secret (AES-256-GCM) |
events |
text[] | Array of subscribed event type strings |
payload_version |
smallint | 1 or 2, enforced by a CHECK; existing webhooks stay on 1, new webhooks default to 2 |
headers |
JSONB | Custom headers sent with each delivery |
status |
enum | active, disabled, or error |
retry_policy |
JSONB | Retired; ignored by the fixed retry schedule |
success_count |
integer | Cumulative successful deliveries |
failure_count |
integer | Cumulative failed deliveries |
last_delivery_at |
timestamp | Timestamp of the most recent delivery attempt |
last_success_at |
timestamp | Timestamp of the most recent successful delivery |
created_by |
UUID | User who created the webhook |
created_at |
timestamp | Creation timestamp |
updated_at |
timestamp | Last modification timestamp |
webhook_deliveries
Section titled “webhook_deliveries”| Column | Type | Description |
|---|---|---|
id |
UUID | Primary key |
webhook_id |
UUID | Foreign key to webhooks |
event_type |
varchar(100) | The event type that triggered this delivery |
event_id |
varchar(100) | The unique event ID |
payload |
JSONB | The stored event data sent to the endpoint |
payload_version |
smallint | Recorded payload version; default 1 for legacy rows. A NOT VALID CHECK permits only 1 or 2 for new or updated rows without scanning the existing table |
event_timestamp |
timestamptz, nullable | Stored wire event timestamp; legacy null values fall back to delivery creation time |
replay_root_event_id |
varchar(100), nullable | Original event id for manual replays; null for original deliveries |
status |
enum | pending (awaiting first attempt), scheduled (awaiting retry), retrying (in flight), delivered, or failed |
attempts |
integer | Number of delivery attempts made |
next_retry_at |
timestamp | Due time (scheduled), execution lease (retrying), or the recovery sweep’s 15-minute lease on a re-driven pending row |
failed_at |
timestamp, nullable | When the delivery became failed; null on legacy rows |
webhook_generation |
integer, nullable | Webhook approval generation when recorded; retries stop after an edit. Legacy null rows fall back to the webhook’s current generation |
response_status |
integer | HTTP response status code from the endpoint |
response_body |
text | Response body (truncated to 1,000 characters) |
response_time_ms |
integer | Round-trip time in milliseconds |
error_message |
text | Error description if the delivery failed |
created_at |
timestamp | When the delivery record was created |
delivered_at |
timestamp | When the delivery was successfully completed |
Troubleshooting
Section titled “Troubleshooting”“Invalid webhook URL” error when creating a webhook.
On hosted Breeze, the URL must use HTTPS and must not resolve to a private, loopback, or link-local IP address (on self-hosted deployments, private RFC 1918/ULA targets are allowed — see URL Safety Validation). Hostnames like localhost, *.localhost, and *.local are always blocked. If the hostname cannot be resolved via DNS, creation will fail.
Webhook is not receiving events.
Check the webhook status – if it is paused or failed, deliveries are not processed. Verify that the events array includes the event types you expect. Use POST /webhooks/:id/test to confirm the endpoint is reachable.
Deliveries remain in scheduled status.
Check nextAttemptAt: retries wait 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, then 12 hours, each with ±20% jitter. The recovery sweep re-drives deliveries more than 15 minutes overdue. After the seventh failed attempt the delivery becomes failed; use manual retry once the endpoint is repaired.
Signature verification fails on my endpoint.
Ensure you are computing the HMAC over the correct input: the raw X-Breeze-Signature-Timestamp header value, followed by a dot (.), followed by the raw request body. Use the X-Breeze-Signature-Timestamp header (not X-Breeze-Timestamp) as the timestamp component. The signature uses SHA-256 and is hex-encoded.
“Unsafe webhook URL” error during delivery. The worker re-validates the URL before each delivery attempt. On hosted Breeze, if your DNS records have changed and the hostname now resolves to a private IP range, deliveries will fail — update the webhook URL to point to a publicly routable address. On self-hosted deployments, private targets are deliverable; only metadata/loopback/link-local resolutions fail.
“Only failed deliveries can be retried” error.
The manual retry endpoint (POST /webhooks/:id/retry/:deliveryId) only accepts deliveries with status failed. Deliveries that are pending, scheduled, retrying, or already delivered cannot be retried through this endpoint.
“Organization context required” error.
For an organization webhook created in partner or system scope, include orgId in the request body. For a partner webhook, use ownerScope: "partner" without orgId; system sessions must also provide partnerId. A list request with an orgId filter returns only that organization’s own webhooks. Omit that filter to include partner webhooks your session can manage.
Response body is truncated. Webhook delivery response bodies are truncated to 1,000 characters to prevent excessive storage consumption. If you need the full response for debugging, check your endpoint’s own logs.