Nexa API · v1

Developers

Conventions

What every endpoint does

The parts of the API that do not change from one resource to the next. Reading this once will save you more time than reading any single endpoint's reference.

Base URL and versioning

Every request goes to:

https://nexa.com.tn/api/v1

The v1 is a promise. Within it we will add endpoints, add optional request fields and add response fields — so your client must tolerate fields it does not recognise. We will not remove a field, rename one, change its type, or make an optional parameter required. Anything in that second list means a v2, and a deprecation window long enough to move.

Responses

A single resource comes back under data:

{
  "data": {
    "id": "b7d41f0a-6e52-4a19-8c33-90ab12cd34ef",
    "object": "asset",
    "name": "Conveyor CV-02",
    "status": "operational"
  }
}

A collection comes back as an array plus a pagination block:

{
  "data": [
    { "id": "b7d41f0a-…", "object": "asset", "name": "Conveyor CV-02" },
    { "id": "6e524a19-…", "object": "asset", "name": "Compressor A" }
  ],
  "pagination": {
    "limit": 50,
    "has_more": true,
    "next_cursor": "eyJvIjo1MH0"
  }
}

Every object carries an object field naming its type. It is there so you can write one dispatch table that handles a list response, a single fetch and a webhook payload, instead of inferring the type from which fields happen to be present.

Field types

  • Timestamps are ISO 8601 in UTC, always with aZ: 2026-09-23T10:04:00.000Z. Dates without a time — an installation date, say — are plain YYYY-MM-DD.
  • Money and quantities with decimals are strings, not numbers. "18400.00", not 18400. Sending them as JSON numbers would route them through an IEEE 754 double on the way to your language, and a currency value that has been silently rounded is the kind of bug that shows up on an invoice months later.
  • IDs are opaque strings. They are UUIDs today. Do not parse them, do not sort by them, and do not assume a length.
  • Absent is null, never a missing key. A field that sometimes disappears forces defensive code on your side for no benefit.

Pagination

List endpoints take limit (default 50, maximum 200) and cursor. To walk a whole collection, pass pagination.next_cursor back as ?cursor= until has_more is false.

async function* allAssets() {
  let cursor = null;

  do {
    const url = new URL("https://nexa.com.tn/api/v1/assets");
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.NEXA_API_KEY}` },
    });

    if (!response.ok) {
      const { error } = await response.json();
      throw new Error(`${error.type}: ${error.message}`);
    }

    const { data, pagination } = await response.json();
    yield* data;

    cursor = pagination.has_more ? pagination.next_cursor : null;
  } while (cursor);
}

for await (const asset of allAssets()) {
  console.log(asset.reference, asset.name);
}

Filtering

Each list endpoint documents the filters it accepts. Combining them narrows with AND.

For incremental synchronisation, assets accept updated_since. Store the timestamp of your last successful run and pass it next time, rather than walking the whole register every night.

Errors

Every error, at every status code, has this shape:

{
  "error": {
    "type": "invalid_request_error",
    "code": "unknown_parameter",
    "message": "Unknown query parameter: statuss. Supported here: cursor, limit, status.",
    "param": "statuss",
    "request_id": "0d7f4b8c-1e93-4a0e-92d7-f4b8c1e935f2"
  }
}

Switch on type for handling and log code for diagnosis. request_id is the thing to quote in a support request — it takes us straight to the call.

StatusTypeWhen
400invalid_request_errorA malformed body, an unknown parameter, a bad cursor. Your code; retrying will not help.
401authentication_errorThe key is missing, unknown, revoked or expired.
402plan_errorThe business’s plan does not include the API.
403permission_errorValid key, missing scope. Also a browser request, which is refused.
404not_found_errorNo such resource — or it belongs to another business. Those are deliberately the same answer.
409conflict_errorAn idempotency key reused with a different body, or a duplicate.
429rate_limit_errorToo many requests. Back off; see below.
5xxapi_errorOur fault. Safe to retry with backoff; a write that used an idempotency key will not be duplicated.

A 400 from validation also carries an errors array with one entry per offending field.

Rate limits

1,500 requests per 15 minutes, per key — roughly 100 a minute. It is counted against the key, not your IP address, so several integrations sharing one egress address do not throttle each other.

Every response tells you where you stand:

  • RateLimit-Limit — your ceiling for the window.
  • RateLimit-Remaining — what is left.
  • RateLimit-Reset — seconds until the window resets.
  • Retry-After — on a 429, how long to wait.

If you are consistently near the limit, the usual cause is polling. Use webhooks instead — that is what they are for — or the hierarchy endpoint, which returns a whole building in one call.

Backing off correctly

async function request(path, init = {}, attempt = 0) {
  const response = await fetch(`https://nexa.com.tn/api/v1${path}`, {
    ...init,
    headers: {
      ...init.headers,
      Authorization: `Bearer ${process.env.NEXA_API_KEY}`,
    },
  });

  // Retry a 429 and a 5xx. Never retry a 4xx — the request was understood
  // and refused, so sending it again changes nothing.
  const retryable = response.status === 429 || response.status >= 500;

  if (retryable && attempt < 5) {
    const retryAfter = Number(response.headers.get("Retry-After"));

    // Honour Retry-After when we send one; otherwise exponential with
    // jitter, so a fleet of workers does not retry in lockstep.
    const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : 2 ** attempt * 1000 * (0.75 + Math.random() * 0.5);

    await new Promise((resolve) => setTimeout(resolve, waitMs));
    return request(path, init, attempt + 1);
  }

  return response;
}

Idempotency

POST /v1/operations and POST /v1/assets require an Idempotency-Key header. Other write endpoints accept one.

The reason is blunt: your HTTP client times out at ten seconds, our handler takes eleven and succeeds, your client retries — and without this, a technician is dispatched to the same machine twice. No amount of care on your side prevents that, because the response can be lost after we have already committed. The API has to make retrying safe.

Send a unique value per logical request — a UUID — and reuse the same one when you retry. We store the response for 24 hours and replay it, with an Idempotency-Replayed: true header, instead of creating a second record.

curl -X POST https://nexa.com.tn/api/v1/operations \
  -H "Authorization: Bearer $NEXA_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "assignee_type": "technician",
    "technician_id": "3a91cc70-1b44-4e9f-bb02-77de55aa1122",
    "title": "Line 2 conveyor — bearing noise",
    "required_skill": "mechanical"
  }'
On this page