Nexa API · v1

Developers

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:

HeaderMeaning
Nexa-Signaturet=<unix seconds>,v1=<hex>. Verify this before trusting anything else in the request.
Nexa-EventThe event type, e.g. operation.created.
Nexa-DeliveryA unique id for this delivery attempt's event. Stable across retries — use it to deduplicate.
Nexa-Webhook-IdThe 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=5e1b8f3c9d2a47e6b0f18c5a7d3e9042b6c1f8a5d7e3b9c2f4a6d8e0b1c3f5a7

To verify it:

  1. Read t and every v1 out of the header.
  2. Check t is within 300 seconds of now. Skipping this step means a captured delivery can be replayed at you forever.
  3. Compute HMAC-SHA256 of the string {t}.{raw body}, keyed with your endpoint’s signing secret, as lowercase hex.
  4. Compare it against each v1 with 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);
  },
);

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:

AttemptWhen
1immediately
2after ~10 seconds
3after ~1 minute
4after ~5 minutes
5after ~30 minutes
6after ~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:

“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.

On this page