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:
| Checkbox | Events |
|---|---|
| Terminals offline and back online | device.offline, device.online for terminals |
| Access points and other gear offline and back online | device.offline, device.online for everything else |
| New tickets, status and assignee changes | ticket.opened, ticket.updated |
| Ticket replies | ticket.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
2xxresponse 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
idstays 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
atif order matters.
Headers
| Header | Description |
|---|---|
X-Polaris-Event | The event type, e.g. device.offline. |
X-Polaris-Delivery | Unique identifier of this delivery. Stays the same across retries. |
X-Polaris-Timestamp | Unix time, in seconds, when this attempt was signed. |
X-Polaris-Signature | sha256= followed by the hex HMAC-SHA256 described below. |
User-Agent | Polaris-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 ) )
- Read the body as raw bytes, before any JSON parsing.
- Compute the signature from
X-Polaris-Timestampand the body, and compare it toX-Polaris-Signaturewith a constant-time comparison. - Reject requests whose timestamp is more than five minutes from your clock, so a captured request can't be replayed later.
- JavaScript
- Go
- PHP
- Python
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);
});
func verify(r *http.Request, secret string) ([]byte, bool) {
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, false
}
ts := r.Header.Get("X-Polaris-Timestamp")
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > 300 {
return nil, false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body)
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return body, hmac.Equal([]byte(want), []byte(r.Header.Get("X-Polaris-Signature")))
}
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_POLARIS_TIMESTAMP'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, getenv('POLARIS_WEBHOOK_SECRET'));
$given = $_SERVER['HTTP_X_POLARIS_SIGNATURE'] ?? '';
if (abs(time() - (int) $ts) > 300 || !hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
import hashlib
import hmac
import os
import time
def verify(headers, body: bytes) -> bool:
ts = headers.get('X-Polaris-Timestamp', '')
secret = os.environ['POLARIS_WEBHOOK_SECRET'].encode()
expected = 'sha256=' + hmac.new(secret, ts.encode() + b'.' + body, hashlib.sha256).hexdigest()
fresh = ts.isdigit() and abs(time.time() - int(ts)) < 300
return fresh and hmac.compare_digest(expected, headers.get('X-Polaris-Signature', ''))
The event object
Every event has the same envelope. Fields that don't apply to an event type are left out.
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier of the event. The same on every retry. |
type | string | device.offline, device.online, ticket.opened, ticket.updated, ticket.replied, or ping. |
at | string | RFC3339 instant Polaris recorded the event. |
workspaceId | string | Workspace the device or ticket belongs to. |
workspace | string | Name of that workspace. |
workspaceSlug | string | Its slug, as used in dashboard links: https://polaris.revlv.com/{workspaceSlug}/… |
device | object | Device events only. See below. |
ticket | object | Ticket events only. See below. |
actor | string | Name of the person who made the ticket change or wrote the reply. Absent for changes Polaris made itself. |
change | object | ticket.updated only. What changed. |
comment | string | ticket.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.
| Field | Type | Description |
|---|---|---|
device.id | string | Unique identifier of the device. Matches id on /v1/devices. |
device.name | string | Display name of the device. |
device.type | string | Terminal, Access Point, Gateway, Switch, and so on. |
device.serial | string | Serial number, when the device has one. |
device.terminal | boolean | true for a site's terminal (the device its internet depends on). |
device.offlineSince | string | RFC3339 instant the device went offline. |
device.onlineAt | string | device.online only. RFC3339 instant it came back. |
{
"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"
}
}
{
"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.
| Field | Type | Description |
|---|---|---|
ticket.id | string | Unique identifier of the ticket. |
ticket.number | integer | Ticket number within its workspace, as shown in the dashboard. |
ticket.title | string | Title of the ticket. |
ticket.status | string | Status when the event happened: open, in_progress, waiting, resolved, closed or cancelled. |
ticket.severity | string | minor, major or critical. |
change.field | string | status, assignee or escalation. |
change.from | string | Previous status, or previous escalation level. |
change.to | string | New 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). |
{
"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" }
}
{
"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.
{
"id": "5a0d3f7e-2b1c-4e8f-9d6a-3c7b1e2f4a90",
"type": "ping",
"at": "2026-10-08T05:30:00Z"
}