Base URL: https://<your-domain>/api/v1. All bodies are JSON (except uploads) and
all timestamps are ISO-8601 with a time zone. The OpenAPI document and Swagger UI
(/docs) are available outside production and disabled in production.
| Method | How |
|---|---|
| User session | POST /auth/login {email, password} returns an access_token (10 min) and a refresh_token. If MFA is enabled it instead returns {mfa_required: true, mfa_token, expires_in, methods}; finish with POST /auth/mfa/verify {mfa_token, code} (a TOTP code or a recovery code) or with a passkey (below). Send Authorization: Bearer <access_token>. |
| Refresh | POST /auth/refresh {refresh_token} issues a new pair. Refresh tokens are single-use; presenting a used one revokes the whole session. |
| API key | Create with POST /api-keys {name, role, scopes[], expires_in_days}. The nxf_… token is shown once; send it as Authorization: Bearer nxf_…. Scopes must be a subset of the role's permissions. |
| OAuth2 password form | POST /auth/token (form-encoded), for tools that expect the OAuth2 password flow. |
| Single sign-on | Through the organization's OpenID Connect identity provider: POST /auth/sso/start, then POST /auth/sso/callback (below). The session it opens is bound to that organization. See SSO.md. |
| SCIM token | nxp_…, created by an owner; valid on /scim/v2 only (below). |
The public signing keys are published at /.well-known/jwks.json.
Sign-up proves the e-mail address first. POST /auth/register {email} answers
202 {"status": "check_email"} for every address and mails it a link
(/complete-signup#token=…, valid 24 hours); an address that already has an
account is sent a notice instead, so the answer reveals nothing. Whoever opens the
link finishes with POST /auth/register/complete {token, password, full_name, organization_name}, which returns 201 and a token pair for the new owner. An
invitation creates the account directly - its link already went to the address:
POST /auth/register/invitation {token, password, full_name} (an existing account
accepts with /auth/invitations/accept instead; this endpoint answers 409 account_exists for it). Starting is limited to 10 an hour per client and 3 an
hour per address; finishing to 60 an hour per client.
Other authentication endpoints:
- sign-up:
/auth/register,/auth/register/complete,/auth/register/invitation; - sign-out:
/auth/logout,/auth/logout-all; - passwords:
/auth/password/change,/auth/password/reset-request,/auth/password/reset; - MFA:
/auth/mfa/enroll,/auth/mfa/confirm(for a member of an organization that requires passkeys who has one, only from a session that signed in with one:403 passkey_session_required),/auth/mfa/disable(turns every second factor off: the authenticator app, all passkeys and the recovery codes - for a member of an organization that requires passkeys, only from a session that signed in with one:403 passkey_session_required); - organizations:
/auth/switch-organization,/auth/invitations/accept; - single sign-on:
/auth/sso/start,/auth/sso/callback.
Passkeys (WebAuthn) are a second factor equal to TOTP; see
PASSKEYS.md. Options and credentials use WebAuthn's own JSON forms
(PublicKeyCredential.parseCreationOptionsFromJSON, credential.toJSON()), with
binary values as unpadded base64url.
| Method | Path | |
|---|---|---|
| POST | /auth/webauthn/register/begin {password} |
Creation options ({options, expires_in}): a single-use challenge for five minutes, bound to the account and session. From a session only; once the account has a second factor, from a session that passed one (403 mfa_session_required); for a member of an organization that requires passkeys, once the account has one, from a session that signed in with one of them (403 passkey_session_required) |
| POST | /auth/webauthn/register/finish {name?, credential} |
201 {passkey, recovery_codes} - the codes only when this passkey turned MFA on. 422 passkey_rejected names the reason in details[0].code; 409 passkey_exists, 409 too_many_passkeys (ten at most) |
| GET | /auth/webauthn/credentials |
My passkeys: name, algorithm, transports, backup flags, times - no key material |
| PATCH | /auth/webauthn/credentials/{id} {name} |
Rename |
| POST | /auth/webauthn/credentials/{id}/delete {password} |
Remove; 409 last_second_factor for the last passkey of an account without TOTP; 403 passkey_session_required as for registering |
| POST | /auth/mfa/webauthn/begin {mfa_token} |
Request options for the sign-in the mfa_token belongs to (409 no_passkeys if the account has none) |
| POST | /auth/mfa/webauthn/verify {mfa_token, credential} |
The token pair; any refusal is 401 mfa_failed and counts toward the lockout |
Passkey endpoints answer 503 passkeys_unavailable where the public URL's host is
not a domain name.
Single sign-on (OpenID Connect, authorization code flow with PKCE; details and the security model in SSO.md):
| Step | Request | Answer |
|---|---|---|
| Start | POST /auth/sso/start {organization: "<slug>"} |
200 {authorization_url, state, binding, expires_in}. Send the browser to authorization_url; keep binding in memory (never in a URL). 404 sso_not_available - the same for an unknown organization and one without usable single sign-on; 503 sso_unavailable if the provider's discovery document cannot be used |
| Callback | The provider redirects to {public_base_url}/sso/callback?code=…&state=…; the front end checks state and posts POST /auth/sso/callback {code, state, binding} |
200 token pair - or {mfa_required: true, mfa_token, expires_in, methods} when the organization requires MFA the provider did not provide and the person has the platform's second factor: finish with /auth/mfa/verify (a TOTP or recovery code) or with a passkey (/auth/mfa/webauthn/begin, then /verify) - either way into a session bound to the organization. An organization that requires passkeys always asks, whatever the provider reported, with methods: ["webauthn"]: only a passkey finishes it |
Callback errors: 401 sso_failed for anything about the state (unknown, used,
expired), the binding or the ID token (one message, the reason is in the
organization's audit trail); 403 sso_email_not_verified, 403 sso_domain_not_allowed
(not an address in one of the organization's verified domains),
403 sso_account_not_managed (from Google: an account the organization's Workspace
does not manage - no hd claim naming a verified domain),
403 sso_access_revoked (deactivated by the organization's directory),
403 ip_not_allowed, 403 mfa_required, 403 passkey_required (no passkey, where the
organization requires one; also from /auth/mfa/verify for a TOTP or recovery code
there), 403 org_inactive; 503 sso_unavailable.
The session a single sign-on opens is bound to its organization:
switch-organization to another one is 403 sso_session_bound, and what belongs
to the account rather than the organization - the personal-data export, the
password change, the MFA and passkey endpoints (/auth/mfa/enroll, /confirm,
/disable, /auth/webauthn/register/*, /auth/webauthn/credentials*) and
/users/me/delete - is 403 sso_session_restricted: use a password sign-in.
An organization that requires single sign-on answers password sessions with
403 sso_required (owners who passed the platform's MFA excepted), including
/auth/login naming it and /auth/switch-organization into it.
An organization that requires passkeys (require_passkey) answers every session that
did not sign in with one with 403 passkey_required - on every request, at
/auth/login naming it and at /auth/switch-organization into it. The MFA and passkey
set-up endpoints answer for the account instead, so a passkey can be registered; API
keys are not affected (PASSKEYS.md, section 4).
Errors always have the same shape. request_id matches the X-Request-ID
response header and the logs:
{"error": "permission_denied", "message": "You are not allowed to perform this action.", "request_id": "…", "details": null}| Status | Meaning |
|---|---|
| 400 | Malformed multipart or protocol-level problem |
| 401 | Missing or invalid credentials (WWW-Authenticate set) |
| 403 | Authenticated but not permitted |
| 404 | Not found or belongs to another tenant |
| 409 | State conflict (e.g. automation_frozen, report_not_ready, duplicate) |
| 413 / 415 | Body too large / unsupported media type |
| 422 | Validation failed: details lists fields and codes, never the submitted values. invalid_cursor for a cursor that does not fit the sort; invalid_value for a value the database cannot store |
| 429 | Rate limited: honour Retry-After |
| 503 | Dependency unavailable or automation kill switch engaged |
Pagination is keyset-based. Query parameters are limit (1 to 200, default 50),
cursor (opaque, from next_cursor) and sort (field or -field, from a
per-resource allowlist). Responses have the form {"items": [...], "next_cursor": "…"|null}.
A cursor is only valid with the sort it was issued for; tampered cursors are
rejected with 422 invalid_cursor.
Idempotency: POST /sources/{id}/runs, POST /workflows/{id}/runs,
POST /intelligence/analyses and POST /reports accept an Idempotency-Key header
(8 to 128 characters of [A-Za-z0-9._:-]). A repeat with the same key returns
200 with the original resource instead of 202 with a new one. A key belongs to
the request it started: sending it with a different request (another source,
workflow, dataset or report period) answers 409 idempotency_key_reused. Keys are
honoured for retention.idempotency_keys_hours (24 by default) and released
within a day after that.
Rate limits (defaults, per principal unless noted):
| Scope | Limit |
|---|---|
| Reads / writes | 600 / 120 per minute |
| Exports and downloads | 20 per hour per person (all of a user's API keys share it) |
| AI analyses | 30 per hour per person (all of a user's API keys share it) |
| Login | 20 per minute per IP, 5 per minute per account |
| Passkeys | Sign-in step: 20 per 5 minutes per IP; registering and managing: 30 per 15 minutes per person |
| Single sign-on | 30 starts and 30 callbacks per minute per IP |
| SCIM | 600 per minute per SCIM token |
| Password change | 5 per 15 minutes per user; a wrong current password also counts toward lockout |
| Webhook receipt | 600 per minute per IP (before verification); 120 per minute per endpoint, counted only for correctly signed deliveries |
Mass assignment: request bodies reject unknown fields with 422. Fields such as
org_id, status or role can never be smuggled into a create or update.
Permissions in brackets. Owners have all permissions; see domain/authorization/roles.py
for the role matrix.
| Method | Path | |
|---|---|---|
| GET/PATCH | /users/me |
Profile |
| GET | /users |
Users of the current organization, with their roles [members:read] |
| POST | /users/me/delete |
Erase own account (password confirmation) |
| GET | /users/me/sessions |
My signed-in sessions: device (a coarse description such as "Firefox on Windows"), IP address, times, MFA (mfa_verified, and mfa_method: the second factor the session passed - totp, recovery_code, webauthn or null), and which one this request uses. Signed-in users only - never with an API key (403 session_required) |
| DELETE | /users/me/sessions/{id} |
End one of my sessions: its access and refresh tokens stop working at once (audited as auth.session.revoked); another user's session is 404 |
| GET | /organizations |
Organizations of the current user |
| GET/PATCH | /organizations/current |
View / update [org:update]. settings is a partial update; allowed_ip_ranges (up to 100 CIDRs, IPv4 or IPv6; null lifts it) confines members' sessions and API keys to those networks - a change that would exclude the caller's own address is 422 would_lock_you_out. require_mfa admits only MFA-verified sessions (403 mfa_required); require_passkey only sessions that signed in with a passkey (403 passkey_required), and turning it on from any other session or with an API key is 422 would_lock_you_out |
| POST | /organizations/current/automation-freeze |
Tenant kill switch with reason [workflows:disable] |
| POST | /organizations/current/deletion |
Request deletion (slug confirmation, 7-day grace) [org:delete] |
| GET, PATCH, DELETE | /organizations/current/members[/{id}] |
Members and roles [members:read / members:manage] |
| GET, POST, DELETE | /organizations/current/invitations[/{id}] |
Invitations [members:manage] |
| GET, POST, DELETE | /api-keys[/{id}] |
API keys [api_keys:manage] |
| GET | /audit, /audit/verify |
Audit trail and hash-chain verification [audit:read] |
| GET, PUT, DELETE | /organizations/current/sso |
The organization's OpenID Connect provider: {issuer, client_id, client_secret, allowed_domains[], default_role (viewer or analyst), sso_required, trust_idp_mfa}. The secret is write-only (required the first time; omitted keeps it) and never returned. The answer lists each domain's TXT record and the redirect_uri to register. 422 sso_discovery_failed, invalid_issuer, invalid_default_role, sso_domain_not_verified, would_lock_you_out; 404 sso_not_configured. A signed-in owner's or administrator's session only (403 session_required for API keys) [org:update] |
| POST | /organizations/current/sso/domains/verify |
Look up each unverified domain's TXT record: {configuration, checks: [{domain, result}]}, result verified, already_verified, record_not_found or lookup_failed [org:update] |
| GET, POST, DELETE | /organizations/current/scim-tokens[/{id}] |
SCIM tokens {name, expires_in_days (1-365)}; the nxp_… token is shown once; at most 10 live ones (409 too_many_tokens). Owners' signed-in sessions only (403 owner_required, session_required) |
| Method | Path | |
|---|---|---|
| GET, POST, GET/PATCH/DELETE | /projects, /projects/{id} |
[projects:read / projects:write]. Deleting a project that still has datasets answers 409 project_not_empty |
| GET, POST, GET/PATCH/DELETE | /datasets, /datasets/{id} |
Typed schema, classification, retention [datasets:*]. The key field cannot be sensitive (422): record keys appear in alerts, reports and AI prompts |
| GET | /datasets/{id}/records |
Current records; sensitive fields masked without [records:read_sensitive] |
| GET | /datasets/{id}/export?format=csv|jsonl |
Streaming, audited export [datasets:export] |
| GET | /records/{id}/history |
Version history [records:read] |
| GET | /changes |
Filter by dataset_id, record_id, change_type, min_significance, since [changes:read] |
| GET | /analytics/changes?project_id=&dataset_id=&period_start=&period_end= |
Change volume of a project or one of its datasets: totals per type and significance, every UTC day of the period (quiet days included), unusual days (robust z-score) and a trend note. Counts only, computed on request; the period defaults to the last 30 days and is at most a year (422 invalid_period) [changes:read] |
| Method | Path | |
|---|---|---|
| GET, POST, GET/PATCH/DELETE | /sources, /sources/{id} |
Website, REST API, webhook and file-upload sources [sources:*] |
| POST | /sources/{id}/runs |
Run now (pull sources) [sources:run] |
| GET | /sources/{id}/runs, /runs/{id} |
Run history and status |
| POST | /sources/{id}/uploads |
multipart/form-data, one part named file (CSV/XLSX). Re-uploading a file that is already live returns 200 with the existing upload; a file whose run failed can be uploaded again (201) [uploads:write] |
| GET | /sources/{id}/uploads |
Upload history |
| GET, POST, GET/DELETE | /integrations, /integrations/{id} |
Credentials; secrets are write-only [integrations:*] |
| POST | /integrations/{id}/rotate, /integrations/{id}/status |
Rotate / revoke / quarantine |
| GET, POST | /webhook-endpoints |
Inbound endpoints; the signing secret is shown once [webhooks:*] |
| POST | /webhook-endpoints/{id}/rotate-secret, /webhook-endpoints/{id}/status |
Rotation (24 h grace), disable |
| Method | Path | |
|---|---|---|
| POST | /intelligence/analyses {dataset_id} |
Queue an analysis [insights:generate] |
| GET | /intelligence/insights[/{id}] |
Insights [insights:read] |
| GET, POST, PATCH, DELETE | /alert-rules[/{id}] |
Conditions: change_type, significance_at_least, field_changed (updates only; use change_type for new or deleted records), numeric_change, insight_risk_at_least, run_failed. Conditions see the complete change, including sensitive fields; the alert shows them masked [alerts:*] |
| GET | /alerts?status= |
Alerts [alerts:read] |
| POST | /alerts/{id}/acknowledge {resolve} |
[alerts:ack] |
| GET, POST, PATCH, DELETE | /channels[/{id}] |
Email, Slack, Telegram, signed webhook [channels:*] |
| POST | /reports |
JSON, CSV, XLSX or PDF for a period of up to a year [reports:generate]. JSON, XLSX and PDF contain the executive summary, totals, the daily trend and a trend note, unusual days, anomalies (the 25 most significant high and critical changes), the 200 most significant changes, alerts, AI insights and sources; XLSX and PDF add a change-volume chart. Totals, the trend and the summary count every change of the period, however many. CSV is one table: the listed changes |
| GET | /reports[/{id}], /reports/{id}/download |
Downloads are attachments with a Repr-Digest SHA-256 header [reports:download] |
| GET, POST, GET/PATCH/DELETE | /workflows[/{id}] |
Scheduled collection → detection → analysis → alerting [workflows:*] |
| POST | /workflows/{id}/status {status, reason} |
Activate, pause or disable (emergency stop) |
| POST, GET | /workflows/{id}/runs |
Run now (idempotent) / history [workflows:execute / read] |
| GET | /dead-letters |
Failed jobs [dead_letters:manage] |
| POST | /dead-letters/{id}/retry, /dead-letters/{id}/discard |
Re-drive idempotent jobs |
POST /api/v1/webhooks/{org_id}/{endpoint_id} with a JSON body and two headers:
X-NexusFlow-Delivery: <unique id, 8-128 chars of [A-Za-z0-9._:-]>
X-NexusFlow-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
The signature is HMAC_SHA256(secret, f"{t}.{delivery_id}." + raw_body):
import hashlib, hmac, json, time, uuid
body = json.dumps({"items": [{"sku": "A-1", "price": "9.99"}]}).encode()
timestamp, delivery = int(time.time()), str(uuid.uuid4())
mac = hmac.new(secret.encode(), f"{timestamp}.{delivery}.".encode() + body, hashlib.sha256).hexdigest()
headers = {
"Content-Type": "application/json",
"X-NexusFlow-Delivery": delivery,
"X-NexusFlow-Signature": f"t={timestamp},v1={mac}",
}| Response | Meaning |
|---|---|
202 {"status": "accepted", "run_id": …, "truncated": false} |
Queued for processing; truncated: true means items beyond the source's max_items were not taken |
200 {"status": "duplicate"} |
Already received and stored; safe to stop retrying |
401 invalid_signature |
Bad signature, stale timestamp or unknown endpoint (indistinguishable) |
409 delivery_in_progress |
An earlier attempt with this delivery id has not finished; retry later with the same id |
409 source_inactive |
The source or organization is not accepting data; retry later |
422 |
The signed body is not valid JSON or does not match the source mapping; fix it and retry with the same delivery id |
429 |
The endpoint's delivery quota is spent; honour Retry-After |
Timestamps must be within 5 minutes. During secret rotation, both secrets are valid
for 24 hours, and a header may carry several v1= values. A delivery that was not
stored (any non-2xx answer) can always be retried under the same delivery id; sign
each attempt with a fresh timestamp.
https://<your-domain>/scim/v2 (RFC 7643/7644) lets an organization's identity
provider provision and deprovision its members; setup and behaviour in
SSO.md. Authenticate with the organization's SCIM
token: Authorization: Bearer nxp_…. Requests and responses are
application/scim+json (application/json is accepted); bodies are capped at 64 KiB.
| Method | Path | |
|---|---|---|
| GET | /ServiceProviderConfig, /ResourceTypes[/User], /Schemas[/{urn}] |
What is supported: PATCH yes; filter yes (200 results at most); bulk, sort, ETags and password changes no |
| GET | /Users?filter=&startIndex=&count= |
A ListResponse. Filters: userName eq "…", externalId eq "…", emails.value eq "…" only (else 400 invalidFilter); count at most 200 |
| POST | /Users |
Provision: userName (an address in a verified domain), externalId, name, displayName, emails, active. 201 with Location; 409 uniqueness for a second entry of the same person or externalId; passwords are refused |
| GET, PUT | /Users/{id} |
Read or replace (userName cannot change: 400 mutability) |
| PATCH | /Users/{id} |
PatchOp add/replace of active, externalId, displayName, name[.givenName/.familyName/.formatted], emails. active: false deprovisions (membership removed, the member's API keys in the organization revoked); active: true provisions again with the default role |
| DELETE | /Users/{id} |
Deprovision and delete the entry - never the account |
Owners are never deactivated or removed (403). Errors use the SCIM Error schema:
{"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "400", "scimType": "invalidFilter", "detail": "…"}, with 401 (a missing, unknown, revoked
or expired token, or any other credential), 403, 404 (also another
organization's resources), 409, 413, 415, 429 (with Retry-After) and 503.
A SCIM token is refused (401) by every endpoint outside /scim/v2.
Datasets are classified public, internal (default), confidential or
restricted. Restricted data never leaves the platform: it is never sent to
an external AI provider (the offline analyser is used even when the
organization opted in), and alert messages - which are sent by e-mail, Slack,
Telegram or webhook - name the changed fields but carry no record keys or values.
The full details stay available in the API to users allowed to see them.
Website, REST API and upload sources with snapshot_mode: "full" mark records that
a complete, fully valid snapshot no longer contains as deleted (soft delete, with a
version entry). A snapshot that would delete more than the source's
max_deletion_ratio (0 to 1, default 0.5) of the live records deletes nothing; the
run succeeds and reports deletions_withheld in its stats. Set the ratio to 1.0
on a source to accept any deletion.
/internal/v1/automation/* (n8n, service tokens) and /internal/v1/sandbox/* (sandbox,
per-run tickets) are served only by the internal app on the private network. See
ARCHITECTURE.md and workflows/n8n.