Webhooks
Events, pushed to you
Register a URL and Nexa POSTs a signed JSON event the moment something happens — an operation opened, a machine's status changed, an alarm raised. It is the alternative to polling, and it is why your integration can react in seconds rather than at the top of the hour.
Registering an endpoint
In the Nexa portal, at Settings → API keys, under Webhook endpoints. A business owner adds the URL and ticks the events they want.
Three rules the URL has to satisfy:
- HTTPS, on port 443. We will not send a signed payload over an unencrypted connection.
- Publicly resolvable. Private ranges, loopback, link-local and internal hostnames are refused — at registration and again at the moment of connecting, on the address your DNS actually returned.
- It has to answer once before it goes live. A new endpoint is created disabled. Pressing Send test event delivers a
webhook.ping, and a 2xx enables it. A URL typed wrong therefore fails in front of the person who typed it, instead of quietly accruing dead deliveries.
The payload
Every delivery is a POST with a JSON body in this envelope:
{
"id": "evt_5f2c1a7e-9b3d-4c81-a0e6-2d7f4b8c1e93",
"type": "operation.created",
"created_at": "2026-09-23T10:04:00.000Z",
"api_version": "2026-09-01",
"data": {
"object": {
"id": "8c1e93a0-2d7f-4b8c-9b3d-5f2c1a7e4c81",
"object": "operation",
"status": "assigned",
"title": "Line 2 conveyor — bearing noise",
"asset_id": "b7d41f0a-6e52-4a19-8c33-90ab12cd34ef",
"technician_id": "3a91cc70-1b44-4e9f-bb02-77de55aa1122",
"scheduled_for": "2026-09-24T07:30:00.000Z"
}
}
}And these headers:
| Header | Meaning |
|---|---|
Nexa-Signature | t=<unix seconds>,v1=<hex>. Verify this before trusting anything else in the request. |
Nexa-Event | The event type, e.g. operation.created. |
Nexa-Delivery | A unique id for this delivery attempt's event. Stable across retries — use it to deduplicate. |
Nexa-Webhook-Id | The id of the endpoint we are delivering to. |
See the event catalogue for every type and an example payload for each.
Verifying signatures
This is the part to get right. Your endpoint is a public URL: anyone who finds it can POST whatever they like to it. The signature is the only thing that distinguishes a real Nexa event from a forged one, and an integration that parses the body without checking it is an integration that will believe anything it is told.
The Nexa-Signature header looks like this:
Nexa-Signature: t=1758621840,v1=5e1b8f3c9d2a47e6b0f18c5a7d3e9042b6c1f8a5d7e3b9c2f4a6d8e0b1c3f5a7To verify it:
- Read
tand everyv1out of the header. - Check
tis within 300 seconds of now. Skipping this step means a captured delivery can be replayed at you forever. - Compute
HMAC-SHA256of the string{t}.{raw body}, keyed with your endpoint’s signing secret, as lowercase hex. - Compare it against each
v1with a constant-time comparison. Accept if any matches.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.NEXA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header, secret) {
if (!header) return false;
const parts = header.split(",").map((p) => p.trim());
const timestampPart = parts.find((p) => p.startsWith("t="));
if (!timestampPart) return false;
const timestamp = Number(timestampPart.slice(2));
if (!Number.isFinite(timestamp)) return false;
// Reject anything too old. Without this check the signature is valid
// forever and a captured delivery can be replayed at you.
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
const expectedBuffer = Buffer.from(expected, "utf8");
// Several v1= values appear during a secret rotation. Accept any match.
return parts.some((part) => {
if (!part.startsWith("v1=")) return false;
const presented = Buffer.from(part.slice(3), "utf8");
// timingSafeEqual THROWS on a length mismatch rather than returning
// false, so the lengths must be compared first.
if (presented.length !== expectedBuffer.length) return false;
return crypto.timingSafeEqual(presented, expectedBuffer);
});
}
// express.raw, NOT express.json — the signature is over the exact bytes.
app.post(
"/hooks/nexa",
express.raw({ type: "application/json" }),
(req, res) => {
const rawBody = req.body.toString("utf8");
if (!verify(rawBody, req.get("Nexa-Signature"), SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
// Acknowledge FIRST, then do the work. See "Responding" below.
res.status(200).send("ok");
queue.add(event);
},
);import hashlib
import hmac
import os
import time
from fastapi import BackgroundTasks, FastAPI, Request, Response
app = FastAPI()
SECRET = os.environ["NEXA_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str | None, secret: bytes) -> bool:
if not header:
return False
parts = [p.strip() for p in header.split(",")]
timestamp_part = next((p for p in parts if p.startswith("t=")), None)
if not timestamp_part:
return False
try:
timestamp = int(timestamp_part[2:])
except ValueError:
return False
# Reject anything too old. Without this check the signature is valid
# forever and a captured delivery can be replayed at you.
if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret, signed, hashlib.sha256).hexdigest()
# Several v1= values appear during a secret rotation. Accept any match.
return any(
hmac.compare_digest(part[3:], expected)
for part in parts
if part.startswith("v1=")
)
@app.post("/hooks/nexa")
async def nexa_webhook(request: Request, background: BackgroundTasks):
raw_body = await request.body()
if not verify(raw_body, request.headers.get("Nexa-Signature"), SECRET):
return Response(status_code=400, content="invalid signature")
event = await request.json()
# Acknowledge first, work afterwards. See "Responding" below.
background.add_task(handle_event, event)
return Response(status_code=200, content="ok")Responding
Answer 2xx as soon as you have verified the signature and durably recorded the event. Anything else is treated as a failure and retried.
Acknowledge before you do the work. If your handler synchronously updates three systems and one of them is slow, we time out at ten seconds, mark the delivery failed and send it again — so the two systems that succeeded get the update twice. Put the event on a queue and return.
Retries and failures
A failed delivery is retried up to six times, with jitter so a recovering endpoint is not hit by a thundering herd:
| Attempt | When |
|---|---|
| 1 | immediately |
| 2 | after ~10 seconds |
| 3 | after ~1 minute |
| 4 | after ~5 minutes |
| 5 | after ~30 minutes |
| 6 | after ~2 hours |
Not everything is retried, and the distinction matters:
- 408, 429, 5xx, timeouts and connection errors are retried. These are transient — a deploy, a blip, a restart.
- Other 4xx responses are not. Your server understood the request and refused it; sending it again changes nothing.
- 410 Gone disables the endpoint immediately. It is the one status that means “stop”, so we honour it. Return it when you decommission a receiver.
After 20 consecutive failures the endpoint is switched off and the business owner is notified. Any success resets the counter. Nothing is lost — the deliveries are still in the log and can be replayed from the portal.
Duplicates and ordering
Delivery is at least once. A network failure after your server committed but before we saw the response means you get the same event again. Deduplicate on Nexa-Delivery, which is stable across retries.
Order is not guaranteed. Retries and independent deliveries mean an operation.updated can arrive before the operation.created it followed. If order matters to you, use created_at on the payload, or treat each event as a prompt to fetch the resource’s current state — which is simpler and always correct.
A replay from the portal carries the payload exactly as it was when the event happened, not a rebuilt one, so replaying a backlog reconstructs the real history rather than a flattened version of it.
Debugging a delivery
Every attempt is logged for 30 days (90 for failures) with the exact payload we signed, the status code you returned and a snippet of your response body. Read it two ways:
- In the portal, under the endpoint — with a Retry button on each row.
- Through the API, at
GET /v1/webhooks/{endpointId}/deliveries, so you do not have to wait on your customer to look.
“We never received it” and “we received it and threw a 500” are one request apart, and they have completely different fixes.
Testing locally
Your machine is not publicly reachable, and we refuse private addresses, so point an endpoint at a tunnel rather than at localhost. Register the tunnel’s https URL, press Send test event, and you get the status code, the latency and your response body back immediately — in the portal, not fifteen seconds later in a log.
To exercise your verification code without a tunnel at all, compute a signature yourself and POST to your own server:
SECRET="whsec_your_endpoint_secret"
BODY='{"id":"evt_test","type":"webhook.ping","created_at":"2026-09-23T10:04:00.000Z","api_version":"2026-09-01","data":{"object":{"object":"ping"}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -hex \
| sed 's/^.* //')
curl -X POST http://localhost:3000/hooks/nexa \
-H "Content-Type: application/json" \
-H "Nexa-Event: webhook.ping" \
-H "Nexa-Signature: t=$T,v1=$SIG" \
-d "$BODY"Then change one byte of BODY without recomputing SIG and confirm your endpoint rejects it. A verification routine that has only ever been tested against valid input has not been tested.