Webhooks
Webhooks are the push side of the API. Instead of your application asking ioX-Pulse for data on a timer, ioX-Pulse sends each event to a URL you choose, as it happens. This is the recommended way to react to new readings, alarms, and devices going on or offline in real time.
Where to find it: Sidebar, then Account settings, then the Integrations tab, then the Webhooks sub-tab.
How webhooks work
- You register a webhook: an HTTPS URL, the events you care about, and an optional device filter.
- When a matching event happens, ioX-Pulse sends an HTTP
POSTto your URL with a JSON body describing the event. - Your endpoint verifies the signature, does its work, and replies with a
2xxstatus code. - If your endpoint is unreachable or returns a non-
2xxstatus, ioX-Pulse retries on a backoff schedule. See Delivery and retries.
Each webhook has its own signing secret, shown when you create it, so you can confirm that a request genuinely came from ioX-Pulse.
Permission required: Partner Admin, or Sub-account Admin for a webhook scoped to a sub-account.
Creating a webhook
- Open Account settings and select the Integrations tab, then Webhooks.
- Click Create webhook.
- Enter a recognizable name (for example,
Ops Slack relay). - Enter the payload URL. It must be
https://. See Security. - Choose the events to subscribe to. See the Event catalog.
- Optionally set a device filter so only some devices trigger delivery. See Filtering which devices fire.
- Click Create.
The signing secret is shown after creation, in the form whsec_.... Store it somewhere safe in your receiver. You will need it to verify signatures. Unlike an API key, the secret stays revealable on the webhook detail view, because you need it to keep verifying deliveries.
Event catalog
| Event | Fires when | data payload |
|---|---|---|
telemetry.reading | A decoded uplink is ingested for a device. | The decoded field values, keyed by field identifier. |
device.online | A device that was offline reports again. | Empty. |
device.offline | A device goes silent past its offline threshold. | lastUplinkAt, offlineThresholdMinutes. |
alarm.triggered | A rule enters an alert state, or changes level. | ruleId, ruleName, level, previousLevel. |
alarm.cleared | An alert state clears. | ruleId, level, reason. |
Notes:
telemetry.readingfires only when a device has a decoder that produced values. A raw frame with nothing decoded does not fire it.device.offlineis detected by a periodic sweep, so it arrives shortly after the threshold elapses, not to the second. The offline threshold is set per device on its Configuration tab.alarm.triggereddoes not repeat for a re-fire at the same level. It fires for a new alarm or a level change (for example, warning to alert).alarm.cleared'sreasonis one ofack,rule_disabled,rule_deleted,expiry,device_moved(the device was unassigned, transferred, or claimed into another account),profile_changed(the device was moved off the profile whose workflow raised the alarm),device_deleted, orsub_closed(the account holding the device was closed).- You may also receive
workflow.fireddeliveries. Those come from a workflow's Webhook action node, not from a webhook integration on this page: they go to the URL on the node, are signed with that node's own secret, and carry a notification-shaped payload instead of the envelope below. Device workflows carry adeviceobject in that payload; gateway workflows carry agatewayobject (id,name,gatewayEui) instead. The signature scheme is identical, so the verification code on this page covers both. See Workflows.
Payload shape
Every delivery has the same envelope. Only the data object differs per event.
{
"event": "telemetry.reading",
"occurredAt": "2026-05-30T14:57:10.000Z",
"partnerId": "445e...cfc",
"subAccountId": "775e...cfc",
"device": {
"id": "d4e9...cfc",
"devEui": "5a4dba5e00000001",
"name": "Cold store probe",
"profileId": "9f1c...a21"
},
"site": {
"id": "a1b2...cfc",
"name": "Main warehouse"
},
"data": { "valve_state": "Closed", "is_poll": true, "temp_c": 21.8 },
"signalQuality": 48,
"retained": { "valve_state": "2026-05-30T11:20:00.000Z" },
"derived": { "temperature_f": 71.2 }
}
event: the event type, from the catalog above.occurredAt: when the event actually happened, as an ISO-8601 timestamp. Fortelemetry.readinganddevice.onlineit is the uplink time. Fordevice.offlineit is the moment the device went silent (its last uplink plus its threshold). For alarm events it is when the rule state changed.partnerIdandsubAccountId: the tenancy the event belongs to.subAccountIdisnullfor a partner-level device.device: the device the event is about, with itsid,devEui,name, andprofileId.site: where the device was when the event happened, as{ id, name }, ornullif the device has no site. Captured at the moment the event fires, so it reflects the device's location at event time even if delivery is delayed. Present only when your tier has multi-site.data: the per-event payload from the table above.signalQuality: the device's Signal Quality % (0-100) at event time, derived from RSSI and SNR. Present ontelemetry.reading(this frame's value),device.online(the recovering uplink's value), anddevice.offline(the last-known value before it went silent). Absent when no signal is known.retained: present only ontelemetry.reading, and only when one or more values were carried forward from an earlier uplink (the Retain last value option). It maps each carried key indatato the ISO-8601 time that value was last actually reported. A key indatabut not inretainedwas reported fresh on this uplink. When nothing was carried, the field is absent, so existing payloads are unchanged until a profile turns retention on.derived: present only ontelemetry.reading, and only when the profile has formula or mapping fields or the device has a calibrated field. It carries this reading's derived values keyed by field identifier: formula and mapping outputs, and the calibrated value of a calibrated field.datastays the decoder's output. A derived value is computed once when the reading arrives and stored with it, so what you receive is exactly what dashboards, history and exports show for that reading; a later change to a formula, mapping or calibration applies to new readings only.
The same envelope is sent to every webhook subscribed to the event, so two endpoints receive identical bodies.
Verifying the signature
Every delivery carries a signature header so you can confirm it came from ioX-Pulse and was not tampered with. Always verify before trusting a payload.
Each request includes these headers:
| Header | Meaning |
|---|---|
X-Pulse-Event | The event type, for quick routing. |
X-Pulse-Delivery | A unique id for this delivery attempt. |
X-Pulse-Signature | The signature, in the form t=<unix>,v1=<hex>. |
The signature is an HMAC-SHA256 of the timestamp and the raw request body, joined with a dot, keyed by your webhook's signing secret:
v1 = HMAC_SHA256(secret, "<t>.<raw-body>")
To verify a request:
- Read
tandv1from theX-Pulse-Signatureheader. - Compute your own HMAC over
"<t>.<raw-body>"using the signing secret. Use the raw body bytes, before any JSON parsing. - Compare your result to
v1with a constant-time comparison. - Reject the request if the values differ, or if
tis more than five minutes from now. The timestamp check limits replay of a captured request.
import crypto from 'node:crypto'
// `rawBody` is the exact bytes received, before JSON.parse.
function verifyPulseSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=')),
)
const t = Number(parts.t)
const v1 = parts.v1
if (!t || !v1) return false
if (Math.abs(Date.now() / 1000 - t) > 300) return false // 5-minute tolerance
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(v1, 'hex')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
Delivery and retries
Deliveries are sent by a background worker, so they arrive within about a minute of the event, not necessarily instantly. Your endpoint should respond quickly (within ten seconds) with any 2xx status. Do the slow work afterward, not while ioX-Pulse waits.
If a delivery fails (a non-2xx status, a timeout, or an unreachable host), it is retried up to four times total on this schedule:
| Attempt | Sent after |
|---|---|
| 1 | immediately |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
After the fourth failed attempt the delivery is marked failed and not retried again.
A dead endpoint is switched off. After many consecutive failures across deliveries, the webhook is automatically disabled so a broken endpoint stops generating traffic. Re-enable it from the Integrations tab once your endpoint is healthy again. Re-enabling clears the failure count.
Make your endpoint idempotent. A retry can deliver the same event twice, and the X-Pulse-Delivery id lets you recognize and skip a duplicate.
Delivery log
Each webhook has a delivery log on its detail view, showing recent attempts with their status, attempt count, and any error. Use it to confirm deliveries are landing and to diagnose failures.
Completed entries (sent or failed) are kept for 14 days and then removed automatically. Deliveries still pending or in flight are never removed, regardless of age.
Pausing a webhook
Each row on the Integrations tab has a switch that pauses or resumes the webhook without deleting it. While a webhook is paused, deliveries are skipped, not queued: events that happen while it is paused are never delivered, and delivery resumes from new events when you switch it back on. The same switch also appears in the webhook's edit drawer.
Turning the switch on also revives a webhook that was automatically disabled after repeated failures; the failure count is cleared.
Filtering which devices fire
By default a webhook fires for every device in its scope. You can narrow it with a device filter when you create or edit the webhook:
- All devices (the default).
- By profile: only devices on the chosen device profiles.
- By device: only the chosen devices.
- By tag: only devices carrying any of the chosen tags.
- By site: only devices at the chosen sites or any site nested under them, based on where the device is when the event fires. Available when your tier has multi-site. See Sites.
Scope still applies on top of the filter. A partner-level webhook can fire for any device in your partner account, including those assigned to sub-accounts. A sub-account webhook fires only for that sub-account's devices.
Security
- HTTPS only. Payload URLs must be
https://. Plainhttp://is rejected. - No internal targets. ioX-Pulse refuses URLs that resolve to private, loopback, link-local, or cloud-metadata addresses, both when you save a webhook and again at delivery time. This protects against requests being aimed at internal infrastructure.
- Verify every payload. Treat the signing secret like a password, and check the signature on every request before acting on it. See Verifying the signature.
Testing
The webhook detail view has a Send test button. It delivers a sample payload to your URL right away and reports the result, so you can confirm your endpoint is reachable and your signature check passes before any real event fires.
If you do not have an endpoint yet, a public request inspector that returns 2xx is a quick way to see a real delivery and its headers.
Next steps
- Overview: how push and pull fit together.
- Authentication: API keys and scopes for the pull API.
- Endpoints: the read endpoints, with examples.