Skip to main content

Webhooks

A webhook sends Polaris activity to a URL you run: devices going offline and coming back, new tickets, ticket status and assignee changes, and ticket replies. Each event is one signed POST with a JSON body, so you can feed it into your own ticketing system, n8n, Zapier or a script.

Webhooks are set up in the Polaris dashboard, not through the API:

  • A workspace's owners and admins add one under Manage → Workspace → Notifications → Add channel → Webhook. It receives events from that workspace and any workspace nested under it.
  • Root admins can add one under Settings → Channels, which receives events from every workspace.

When you add the webhook, Polaris shows its signing secret (whsec_…). Keep it with your receiver; you can see it again under Edit, and replace it with New secret from the channel's menu. Send test posts a ping event straight away.

The checkboxes on the channel pick what it receives:

CheckboxEvents
Terminals offline and back onlinedevice.offline, device.online for terminals
Access points and other gear offline and back onlinedevice.offline, device.online for everything else
New tickets, status and assignee changesticket.opened, ticket.updated
Ticket repliesticket.replied

Internal ticket notes are never sent to a workspace's webhook.


Delivery​

  • Polaris posts each event within a minute or two of it happening, one event per request, with Content-Type: application/json.
  • Your endpoint must use https:// and resolve to a public address. Redirects are not followed.
  • Any 2xx response counts as delivered. Anything else, or no response within 10 seconds, is a failure.
  • A failed event is retried after 1, 2, 4, 8, 16 and then every 30 minutes, for up to 24 hours. The channel shows Failing with the last error until a delivery gets through.
  • The same event can arrive more than once, for example when your endpoint saved it but timed out before answering. Each event's id stays the same across retries: use it to skip events you have already handled.
  • Events usually arrive in order, but retries can deliver an older event after a newer one. Compare at if order matters.

Headers​

HeaderDescription
X-Polaris-EventThe event type, e.g. device.offline.
X-Polaris-DeliveryUnique identifier of this delivery. Stays the same across retries.
X-Polaris-TimestampUnix time, in seconds, when this attempt was signed.
X-Polaris-Signaturesha256= followed by the hex HMAC-SHA256 described below.
User-AgentPolaris-Webhooks/1

Verifying signatures​

Check every request before you trust it. The signature is an HMAC-SHA256, keyed with your signing secret, of the timestamp header, a dot, and the raw request body:

sha256=hex( HMAC_SHA256( secret, X-Polaris-Timestamp + "." + body ) )
  1. Read the body as raw bytes, before any JSON parsing.
  2. Compute the signature from X-Polaris-Timestamp and the body, and compare it to X-Polaris-Signature with a constant-time comparison.
  3. Reject requests whose timestamp is more than five minutes from your clock, so a captured request can't be replayed later.
Verify a delivery
import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/polaris', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-Polaris-Timestamp') ?? '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.POLARIS_WEBHOOK_SECRET)
.update(`${ts}.${req.body}`)
.digest('hex');
const given = req.get('X-Polaris-Signature') ?? '';
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
if (!fresh || given.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
return res.sendStatus(401);
}

const event = JSON.parse(req.body);
// event.id is the same on every retry: skip ids you have already handled.
res.sendStatus(204);
});

The event object​

Every event has the same envelope. Fields that don't apply to an event type are left out.

FieldTypeDescription
idstringUnique identifier of the event. The same on every retry.
typestringdevice.offline, device.online, ticket.opened, ticket.updated, ticket.replied, or ping.
atstringRFC3339 instant Polaris recorded the event.
workspaceIdstringWorkspace the device or ticket belongs to.
workspacestringName of that workspace.
workspaceSlugstringIts slug, as used in dashboard links: https://polaris.revlv.com/{workspaceSlug}/…
deviceobjectDevice events only. See below.
ticketobjectTicket events only. See below.
actorstringName of the person who made the ticket change or wrote the reply. Absent for changes Polaris made itself.
changeobjectticket.updated only. What changed.
commentstringticket.replied only. The full text of the reply.

Device events​

device.offline is sent when a device stops reporting and device.online when it comes back.

FieldTypeDescription
device.idstringUnique identifier of the device. Matches id on /v1/devices.
device.namestringDisplay name of the device.
device.typestringTerminal, Access Point, Gateway, Switch, and so on.
device.serialstringSerial number, when the device has one.
device.terminalbooleantrue for a site's terminal (the device its internet depends on).
device.offlineSincestringRFC3339 instant the device went offline.
device.onlineAtstringdevice.online only. RFC3339 instant it came back.
device.offline
{
"id": "2cd493bf-872a-4d8a-953e-b40ebcb175cb:device.offline",
"type": "device.offline",
"at": "2026-10-08T05:18:20.147851Z",
"workspaceId": "00000000-0000-0000-0000-000000000001",
"workspace": "DICT Region IV-A",
"workspaceSlug": "dict-r4a",
"device": {
"id": "b419fd99-9dc1-4ecb-958b-8d7c06b8f270",
"name": "DICT R4A_Brgy Hall_Pinagbayanan",
"type": "Terminal",
"serial": "KIT10000000042",
"terminal": true,
"offlineSince": "2026-10-08T05:14:20.147851Z"
}
}
device.online
{
"id": "2cd493bf-872a-4d8a-953e-b40ebcb175cb:device.online",
"type": "device.online",
"at": "2026-10-08T05:19:03.797105Z",
"workspaceId": "00000000-0000-0000-0000-000000000001",
"workspace": "DICT Region IV-A",
"workspaceSlug": "dict-r4a",
"device": {
"id": "b419fd99-9dc1-4ecb-958b-8d7c06b8f270",
"name": "DICT R4A_Brgy Hall_Pinagbayanan",
"type": "Terminal",
"serial": "KIT10000000042",
"terminal": true,
"offlineSince": "2026-10-08T05:14:20.147851Z",
"onlineAt": "2026-10-08T05:19:03.797105Z"
}
}

Ticket events​

ticket.opened is sent for every new ticket, including the ones Polaris opens when a device stays offline. ticket.updated is sent when a ticket's status or assignee changes or it is escalated. ticket.replied is sent for each reply.

FieldTypeDescription
ticket.idstringUnique identifier of the ticket.
ticket.numberintegerTicket number within its workspace, as shown in the dashboard.
ticket.titlestringTitle of the ticket.
ticket.statusstringStatus when the event happened: open, in_progress, waiting, resolved, closed or cancelled.
ticket.severitystringminor, major or critical.
change.fieldstringstatus, assignee or escalation.
change.fromstringPrevious status, or previous escalation level.
change.tostringNew status, the new assignee's name (absent when the ticket was unassigned), or the new escalation level (1 field office, 2 ITD, 3 service provider).
ticket.updated
{
"id": "6f1c0a52-3b7e-4d1a-9a55-0c2f61d8b4e0",
"type": "ticket.updated",
"at": "2026-10-08T06:02:11.408Z",
"workspaceId": "00000000-0000-0000-0000-000000000001",
"workspace": "DICT Region IV-A",
"workspaceSlug": "dict-r4a",
"ticket": {
"id": "0d7e9b0a-1c4f-4d3e-8a8f-5b1f2a3c4d5e",
"number": 128,
"title": "Pinagbayanan terminal offline",
"status": "resolved",
"severity": "major"
},
"actor": "Ana Reyes",
"change": { "field": "status", "from": "in_progress", "to": "resolved" }
}
ticket.replied
{
"id": "9b4f2e71-8c3d-4a6b-b1e0-7f5a3c2d1e09",
"type": "ticket.replied",
"at": "2026-10-08T06:05:42.117Z",
"workspaceId": "00000000-0000-0000-0000-000000000001",
"workspace": "DICT Region IV-A",
"workspaceSlug": "dict-r4a",
"ticket": {
"id": "0d7e9b0a-1c4f-4d3e-8a8f-5b1f2a3c4d5e",
"number": 128,
"title": "Pinagbayanan terminal offline",
"status": "resolved",
"severity": "major"
},
"actor": "Ana Reyes",
"comment": "Power was restored at the barangay hall. The terminal is back online."
}

Ping​

Send test posts a ping. It carries only id, type and at, and is signed like every other event.

ping
{
"id": "5a0d3f7e-2b1c-4e8f-9d6a-3c7b1e2f4a90",
"type": "ping",
"at": "2026-10-08T05:30:00Z"
}