CUSTOMER API
Read your own security data from your own systems
Incidents, alerts and your security score, fetched by a backend service holding a scoped key. Deliberately small, deliberately read-only, and enforced on the server rather than by convention.
Authentication
One header, on every request:
X-API-Key: ngk_<prefix>_<secret>- A key is bound to one organisation. The tenant comes from the key. No request field names a tenant, so a caller cannot ask for anybody else's data however the request is shaped.
- The secret is shown once, at creation and rotation. Only a hash is stored, so a lost key is rotated rather than recovered — nobody, including us, can read it back.
- Scopes are explicit. A route refuses a key whose scopes do not name what it is asking for, and says which scope is missing. A key created without scopes can read nothing.
- Keys belong in a backend service, never in browser or mobile code. Anything a browser can read, anybody who opens the page can read.
Scopes
Every scope is read-only. There is no scope that changes anything, approves anything, or touches another organisation. Platform administration, response approvals, rule authoring and privacy erasure are not scopes that are switched off — there is no scope for them at all.
| Scope | What it reads |
|---|---|
incidents:read | Incidents and their timelines |
alerts:read | Individual alerts |
security_score:read | The current score, its categories, and why it moved |
Endpoints
Five, all GET. The machine-readable contract is at /api/v1/openapi.json and describes these and nothing else.
GET /api/v1/public/identity— what this key is, and which organisation it belongs toGET /api/v1/public/incidents— incidents, newest firstGET /api/v1/public/incidents/{incident_id}— one incident and its timelineGET /api/v1/public/alerts— individual findingsGET /api/v1/public/security-score— the current score and why it moved
A first request
Start with identity: it answers with the organisation the key is bound to, which confirms the key works before anything depends on the data.
curl -sS "https://securiteagi.com/api/v1/public/identity" \
-H "X-API-Key: $NEXGUARD_API_KEY"Lists, filtering and paging
incidents and alerts take status, limit and cursor. Every list answers with its rows and a page object:
{ "incidents": [ ... ],
"page": { "has_more": true, "next_cursor": "MjAyNi0wOS0x..." } }Pass next_cursor back as cursor to continue. Stop when has_more is false. There is no total count, on purpose: has_more is answered by reading one row past the page, so a tenant with a million alerts pays what a tenant with ten pays.
Rate limits
Two limits, and they are not counted the same way. The one that catches people out is the first row: sending is measured in events, not requests, so a batch of fifty costs fifty.
| What you call | Limit | Counted in | Keyed by | Window |
|---|---|---|---|---|
POST /api/v1/events, POST /api/v1/events/batch | 6,000 / minute | events — a 50-event batch costs 50 | API key | fixed clock minute |
| The five public reads | 120 / minute | requests | API key | fixed clock minute |
Reading is limited far more tightly than sending, deliberately: events are a firehose a customer cannot batch away, while reading incidents is a poll — and a poll that needs more than 120 a minute wants a webhook instead.
Per key, so one noisy integration cannot starve your others, and a single compromised key can be throttled on its own. Do not hardcode the number: /public/identity reports rate_limit_per_minute for the key you are holding, alongside its scopes and expiry.
The window is a fixed clock minute, not a rolling sixty seconds. Two consequences worth designing for: the allowance resets at the top of each minute however recently you spent it, and a burst placed either side of that boundary can put up to twice the limit into one sixty-second span without being refused. Pace against the limit rather than against the clock.
Two different refusals, and they mean opposite things
Both are temporary and both are worth retrying, but they are not the same event and a client that treats them alike will either hammer us or give up too early.
429 Too Many Requests | 503 Service Unavailable | |
|---|---|---|
| What happened | You went over your own allowance | We were at capacity — nothing to do with your rate |
| Whose limit | Yours, and only yours | The service, shared by everyone |
Retry-After | Seconds left in your current window | A short hint; capacity clears in seconds |
| What to do | Slow down, then continue | Back off and retry — do not slow your steady rate |
| Was anything stored? | No | No. A refused batch is stored in full or not at all |
Neither is 500. A 500 means something is broken and the request is not worth repeating; if you see one, quote the request_id rather than retrying in a loop.
Retrying safely
Sending is idempotent on event_id. Re-sending a batch you already sent stores nothing a second time and raises nothing a second time — the response tells you which events were duplicates:
{ "accepted": 0, "duplicates": 1, "rejected": 0,
"events": [ { "event_id": "your-id", "duplicate": true, "stored_id": null } ] }That is what makes the dangerous case safe: not the request that was refused, but the one that succeeded and whose response you never received. You cannot tell those apart, so send it again. Use a stable event_id derived from the event itself, not a fresh one per attempt — a new id on a retry is a new event, and it will be counted as one.
Retry with bounded attempts, exponential backoff and full jitter. Jitter is not decoration: without it every client refused in the same second returns in the same second, and the retry is a second outage.
async function send(batch) {
const MAX_ATTEMPTS = 5; // bounded: give up and alert, never loop forever
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
const response = await fetch("https://securiteagi.com/api/v1/events/batch", {
method: "POST",
headers: { "X-API-Key": key, "Content-Type": "application/json" },
body: JSON.stringify(batch), // same event_ids on every attempt
});
if (response.ok) return response.json();
if (response.status !== 429 && response.status !== 503) {
throw new Error(`giving up: ${response.status}`); // 4xx/500: retrying cannot help
}
const after = Number(response.headers.get("Retry-After")) || 0;
const backoff = Math.min(30_000, 500 * 2 ** attempt);
const wait = Math.max(after * 1000, Math.random() * backoff); // full jitter
await new Promise((r) => setTimeout(r, wait));
}
throw new Error("still refused after 5 attempts"); // queue it, alert, do not spin
}Hold unsent batches in your own bounded buffer and drop oldest first if it fills. An unbounded retry queue turns a few minutes of our unavailability into your outage.
When something goes wrong
Errors are application/problem+json, and every one carries a request_id. Quote it if you ask us about a request.
{ "type": "about:blank", "title": "Unauthorized",
"status": 401, "detail": "Unauthorized",
"request_id": "60f86c23-f110-4ca2-99d9-06ef3c81cc6f" }| Status | Meaning |
|---|---|
401 | No key, or a key that is unknown, revoked or expired. Authentication is checked before anything is looked up, so an unauthenticated request gets this even when the rest of it is malformed — which is why a 401 never tells you whether something exists. |
403 | A valid key without the scope this route needs. The body names the missing scope. |
404 | Authenticated, authorised, and no such record here. |
422 | Your key is valid and something in the request is not: a parameter out of range, or an identifier that is not the shape an identifier takes. Measured: a malformed incident id answers 422 with a valid key, and 401 without one. |
429 | Too many requests. Wait for Retry-After and retry. |
Timestamps are UTC, ISO 8601, and end in Z — for example opened_at and last_activity_at on an incident.
Getting a key, and asking us things
Keys are created in the console under Settings → Developers & API, which also shows this deployment's base URL, the scopes available to you, and worked examples in curl, JavaScript and Python. Creating one needs the api_keys:manage permission; an administrator of your organisation can grant it.
What this is not
It is not a way to embed the NexGuard interface, connect an identity provider, or push configuration. It reads what your organisation already has here. Sending telemetry is a separate capability asked for when a key is created, and it is off unless stated.