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/v1The 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 a
Z:2026-09-23T10:04:00.000Z. Dates without a time — an installation date, say — are plainYYYY-MM-DD. - Money and quantities with decimals are strings, not numbers.
"18400.00", not18400. 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);
}import os
import requests
def all_assets():
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['NEXA_API_KEY']}"
cursor = None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
response = session.get("https://nexa.com.tn/api/v1/assets", params=params)
response.raise_for_status()
body = response.json()
yield from body["data"]
if not body["pagination"]["has_more"]:
return
cursor = body["pagination"]["next_cursor"]
for asset in all_assets():
print(asset["reference"], asset["name"])# First page
curl "https://nexa.com.tn/api/v1/assets?limit=100" \
-H "Authorization: Bearer $NEXA_API_KEY"
# Next page: pass back next_cursor verbatim
curl "https://nexa.com.tn/api/v1/assets?limit=100&cursor=eyJvIjoxMDB9" \
-H "Authorization: Bearer $NEXA_API_KEY"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.
| Status | Type | When |
|---|---|---|
| 400 | invalid_request_error | A malformed body, an unknown parameter, a bad cursor. Your code; retrying will not help. |
| 401 | authentication_error | The key is missing, unknown, revoked or expired. |
| 402 | plan_error | The business’s plan does not include the API. |
| 403 | permission_error | Valid key, missing scope. Also a browser request, which is refused. |
| 404 | not_found_error | No such resource — or it belongs to another business. Those are deliberately the same answer. |
| 409 | conflict_error | An idempotency key reused with a different body, or a duplicate. |
| 429 | rate_limit_error | Too many requests. Back off; see below. |
| 5xx | api_error | Our 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 a429, 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;
}import os
import random
import time
import requests
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['NEXA_API_KEY']}"
def request(method, path, attempt=0, **kwargs):
response = session.request(method, f"https://nexa.com.tn/api/v1{path}", **kwargs)
# Retry a 429 and a 5xx. Never retry a 4xx.
retryable = response.status_code == 429 or response.status_code >= 500
if retryable and attempt < 5:
retry_after = response.headers.get("Retry-After")
wait = (
float(retry_after)
if retry_after
else (2 ** attempt) * random.uniform(0.75, 1.25)
)
time.sleep(wait)
return request(method, path, attempt + 1, **kwargs)
return responseIdempotency
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"
}'// Generate the key ONCE, outside the retry loop. Generating a fresh one
// per attempt defeats the entire mechanism.
const idempotencyKey = crypto.randomUUID();
async function createOperation(attempt = 0) {
const response = await fetch("https://nexa.com.tn/api/v1/operations", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEXA_API_KEY}`,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify({
assignee_type: "technician",
technician_id: "3a91cc70-1b44-4e9f-bb02-77de55aa1122",
title: "Line 2 conveyor — bearing noise",
required_skill: "mechanical",
}),
});
if (response.status >= 500 && attempt < 3) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
return createOperation(attempt + 1);
}
return response.json();
}