REST API and webhooks
Everything the Modulix web app does goes through a versioned REST API, so integrations can do the same. This page covers the conventions every endpoint shares: how to authenticate, choose an organization, read errors, page through lists and receive webhooks.
Base URL and OpenAPI
All endpoints are under /api/v1. Core endpoints sit directly below it (/api/v1/users, /api/v1/settings/...); each module's endpoints are under its id (/api/v1/notes, /api/v1/contacts/..., /api/v1/sales/invoices).
| What | Path |
|---|---|
| API base | /api/v1 |
| Interactive documentation (Swagger UI) | /api/docs |
| OpenAPI 3 document (JSON) | /api/docs/openapi.json |
Health checks (API server only, no /api/v1 prefix) | /health/live, /health/ready |
You can reach the API in two ways:
- Through the web app's address, for example
https://erp.example.com/api/v1/.... The web server forwards every/api/*request to the API server. This is the usual choice for integrations. - Directly on the API server (port
4000by default), for example from inside your network.
The OpenAPI document describes the core and every module included in the deployment, with request and response schemas generated from the same zod schemas that validate requests. Use it to generate a client or to look up a module's endpoints. To export it without running the server:
pnpm --filter @modulix/api openapi --out openapi.json
The OpenAPI document lists every module built into the deployment. An endpoint of a module that is not enabled for your organization returns 404 Not Found.
The examples on this page use these shell variables:
export MODULIX_URL="https://erp.example.com"
export MODULIX_KEY="mx_xxxxxxxx_..." # an API key (see below)
Authentication
Every endpoint requires authentication unless it is explicitly public (sign-in, sign-up, password reset, invitations, the OAuth token endpoint, public forms and signed download links). Modulix accepts three kinds of credentials.
| Credential | Send it as | Best for |
|---|---|---|
| API key | X-Api-Key: mx_… or Authorization: Bearer mx_… | Scripts and integrations |
| OAuth client token | Authorization: Bearer <token> from POST /api/v1/oauth/token | Server-to-server integrations that prefer short-lived tokens |
| User access token (JWT) | Authorization: Bearer <token> from POST /api/v1/auth/login | The web app and tools acting as a person |
API keys
API keys are created in Administration → API keys (permission core.apikeys.manage) or with POST /api/v1/api-keys. A key:
- looks like
mx_<prefix>_<secret>; the full key is shown once, at creation, and only its SHA-256 hash is stored; - belongs to one organization;
- has scopes: a list of permission keys (wildcards such as
notes.*are allowed) that cannot exceed the permissions of the person who creates it; - can have an expiry date (
expiresAt), and can be revoked at any time. Revoked or expired keys get401.
curl "$MODULIX_URL/api/v1/notes" \
-H "X-Api-Key: $MODULIX_KEY"
# Equivalent
curl "$MODULIX_URL/api/v1/notes" \
-H "Authorization: Bearer $MODULIX_KEY"
Creating a key through the API (as a signed-in administrator):
curl -X POST "$MODULIX_URL/api/v1/api-keys" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Tenant: brightline" \
-H "Content-Type: application/json" \
-d '{ "name": "Accounting sync", "scopes": ["contacts.*", "sales.invoice.read"], "expiresAt": "2027-12-31T23:59:59Z" }'
{ "id": "0199…", "key": "mx_3f9c2a1b_…", "prefix": "3f9c2a1b" }
Requests made with a key are recorded in the audit log as the person who created the key.
OAuth clients (client credentials)
An OAuth client is another way for a server to authenticate: it exchanges a client id and secret for a bearer token that lasts one hour. Clients are created in Administration → API keys → OAuth clients (or POST /api/v1/oauth-clients, permission core.apikeys.manage). Like API keys, they belong to one organization and carry scopes no wider than their creator's permissions. The client secret is shown once. Revoking a client stops its tokens at once.
Request a token with JSON:
curl -X POST "$MODULIX_URL/api/v1/oauth/token" \
-H "Content-Type: application/json" \
-d '{ "grant_type": "client_credentials", "client_id": "mxc_…", "client_secret": "mxs_…" }'
or with HTTP Basic authentication:
curl -X POST "$MODULIX_URL/api/v1/oauth/token" \
-u "mxc_…:mxs_…" \
-H "Content-Type: application/json" \
-d '{ "grant_type": "client_credentials" }'
{ "access_token": "eyJhbGciOiJIUzI1NiJ9…", "token_type": "Bearer", "expires_in": 3600, "scope": "contacts.* sales.invoice.read" }
Then call the API with Authorization: Bearer <access_token>, and request a new token when it expires. The token endpoint is limited to 30 requests per minute per address.
User tokens (sign-in)
The web app signs people in with an email and password:
curl -X POST "$MODULIX_URL/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{ "email": "you@example.com", "password": "…", "tenant": "brightline" }'
The response holds accessToken (a JWT, valid 15 minutes by default), refreshToken, expiresIn (seconds) and user. The refresh token is also set as an httpOnly cookie scoped to /api/v1/auth.
POST /api/v1/auth/refreshwith{ "refreshToken": "…" }(or the cookie) returns a new access token and a new refresh token. Refresh tokens rotate on every use; reusing an old one revokes the whole session.POST /api/v1/auth/logoutrevokes the refresh token.
For integrations, prefer API keys or OAuth clients. User tokens are tied to a person's password and session, expire quickly and are meant for interactive clients.
Choosing the organization
Most endpoints work on one organization (a tenant in the API). Modulix resolves it per request:
| Credential | How the organization is chosen |
|---|---|
| API key or OAuth client | Always the organization the key or client belongs to. If you also send X-Tenant, it must name that organization, or the request is refused with 403 |
| User token | 1. The X-Tenant header, if present. 2. The organization chosen at sign-in (the token's tid claim). 3. The person's only organization, if they belong to exactly one |
X-Tenant accepts the organization's id or its slug:
curl "$MODULIX_URL/api/v1/me/context" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Tenant: brightline"
- An unknown or inactive organization returns
404. - A person who is not an active member gets
403(platform administrators excepted). - When no organization can be resolved, the API returns
400withTenant required: send the X-Tenant header with a tenant id or slug.
GET /api/v1/tenants lists the organizations the signed-in person belongs to.
Permissions and scopes
Every endpoint declares the permission it needs, such as notes.note.read or core.webhooks.manage. Permission keys have the form <module>.<resource>.<action>.
- People get permissions from their roles in the organization (Administration → Roles & permissions).
- API keys and OAuth clients get exactly their scopes.
- Wildcards match whole namespaces:
*is everything,contacts.*is every Contacts permission. - Some record permissions can be granted for a person's own or team's records only. Lists then return only those records, and other records answer
404or403.
A request without the permission gets 403 Forbidden with Missing permission: <key> in detail, and the refusal is written to the audit log (access.denied).
GET /api/v1/permissions (permission core.roles.read) lists the permission keys of the core and of every module enabled for the organization, which is useful when choosing scopes.
Requests and responses
- Send and receive JSON (
Content-Type: application/json), except for file uploads, which useapplication/octet-streambodies on the endpoints that accept files. - Ids are UUIDs (version 7).
- Dates are
YYYY-MM-DDstrings; timestamps are ISO 8601 strings in UTC. - Money amounts and quantities are decimal strings (
"1250.00"), never floating-point numbers. Send them as strings too. POSTreturns the created record;PATCHtakes only the fields to change and returns the updated record;DELETEreturns204 No Content.
Creating a note:
curl -X POST "$MODULIX_URL/api/v1/notes" \
-H "X-Api-Key: $MODULIX_KEY" \
-H "Content-Type: application/json" \
-d '{ "title": "Call the supplier", "body": "Ask about the March delivery.", "color": "blue" }'
{
"id": "0199a3c4-7b2e-7c51-9a0e-5d7f2e1b8c90",
"tenantId": "0199a1f0-…",
"title": "Call the supplier",
"body": "Ask about the March delivery.",
"color": "blue",
"pinned": false,
"createdBy": "0199a1f0-…",
"createdAt": "2026-10-19T14:02:11.000Z",
"updatedAt": "2026-10-19T14:02:11.000Z"
}
Errors
Every error is returned as an RFC 7807 problem document with the content type application/problem+json:
{
"type": "about:blank",
"title": "Forbidden",
"status": 403,
"detail": "Missing permission: sales.invoice.write"
}
| Member | Meaning |
|---|---|
title | The HTTP status in words (Not found, Conflict, Validation failed, ...) |
status | The HTTP status code |
detail | A human-readable explanation, in English. Optional |
errors | Field problems, for validation errors: [{ path, message, code }] |
| other members | Some errors add machine-readable members, such as code |
Validation errors (422)
Invalid bodies, query parameters and business-rule violations return 422 Unprocessable Entity with one entry per problem. path uses dots for nested fields and array indexes (lines.0.quantity).
{
"type": "about:blank",
"title": "Validation failed",
"status": 422,
"errors": [
{ "path": "title", "message": "Too small: expected string to have >=1 characters", "code": "too_small" },
{ "path": "color", "message": "Invalid option: expected one of \"yellow\"|\"blue\"|\"green\"|\"pink\"", "code": "invalid_value" }
]
}
Status codes
| Status | When |
|---|---|
400 Bad Request | Malformed request, or no organization could be resolved |
401 Unauthorized | Missing, invalid, expired or revoked credentials (Authentication required, Invalid API key, Invalid or expired access token) |
403 Forbidden | Missing permission, not a member of the organization, or an API key used with another organization's X-Tenant |
404 Not Found | The record does not exist or is not visible to you, the organization is unknown, or the module is not enabled for the organization |
409 Conflict | The request conflicts with the current state: a duplicate, a status that does not allow the change, or a record still in use ("code": "inUse", with the referring records in detail) |
413 Payload Too Large | An uploaded file is over the limit (JSON bodies are limited to 5 MB) |
422 Unprocessable Entity | Validation failed (see above) |
429 Too Many Requests | Rate limit exceeded |
500 Internal Server Error | An unexpected error; details are in the server log, not the response |
Lists: paging, search, sorting and filters
Record lists (contacts, companies, products, quotations, orders, invoices, bills, and most other business records) share the same query parameters and response shape:
| Parameter | Meaning |
|---|---|
page | Page number, starting at 1 (default 1) |
pageSize | Records per page (default 25; at most 200 for most lists, 100 for contacts and companies) |
q | Text search across the record type's searchable fields (and custom fields, where supported) |
sort | Field to sort by (sortable fields of the record type, createdAt or updatedAt) |
dir | asc or desc |
filter | A JSON filter (see below), URL-encoded |
| record-specific parameters | Equality filters a record type declares, such as status, categoryId or active. Several values are separated by commas; none matches empty values; me matches the caller for person fields |
cf.<key> | A custom field filter, where the record type supports custom fields |
{ "total": 142, "page": 2, "pageSize": 50, "items": [ { "id": "…", "name": "…" } ] }
# Active products, most expensive first, second page of 50
curl -G "$MODULIX_URL/api/v1/catalog/products" \
-H "X-Api-Key: $MODULIX_KEY" \
--data-urlencode "active=true" \
--data-urlencode "sort=salesPrice" \
--data-urlencode "dir=desc" \
--data-urlencode "page=2" \
--data-urlencode "pageSize=50"
Filters
filter is a group of up to 20 conditions combined with all (AND) or any (OR):
{
"match": "all",
"rules": [
{ "field": "status", "op": "in", "value": ["issued"] },
{ "field": "dueDate", "op": "between", "value": ["2026-10-01", "2026-10-31"] }
]
}
| Field type | Operators |
|---|---|
| Text (short) | contains, notContains, eq, neq, startsWith, empty, notEmpty |
| Text (long) | contains, notContains, empty, notEmpty |
| Number | eq, neq, gt, gte, lt, lte, between, empty, notEmpty |
| Date | eq, gt, gte, lt, lte, between, empty, notEmpty |
| Yes/no | isTrue, isFalse |
| Choice, list | in, notIn, empty, notEmpty |
between takes [from, to]; in and notIn take a list; empty, notEmpty, isTrue and isFalse take no value. GET …/fields on a record list (for example /api/v1/catalog/products/fields) returns the fields you can filter on, including custom fields as cf.<key>. An invalid filter returns 422 with paths such as filter.rules.0.op.
curl -G "$MODULIX_URL/api/v1/sales/invoices" \
-H "X-Api-Key: $MODULIX_KEY" \
--data-urlencode 'filter={"match":"all","rules":[{"field":"status","op":"in","value":["issued"]},{"field":"dueDate","op":"lt","value":"2026-10-19"}]}'
Lookups
GET …/lookup?q=<text> (or ?ids=<id>,<id>) on a record list returns a short list of { id, label } for pickers. GET /api/v1/search/lookup?entity=<record type>&q=<text> does the same for any record type, and GET /api/v1/search?q=<text> searches every record type you may read.
Other lists
A few endpoints use other conventions; the OpenAPI document is authoritative. For example, the audit log (GET /api/v1/audit) takes limit (up to 500) and offset and returns the number of matching entries in the X-Total-Count header, and some small lists (such as notes) return a plain array.
Rate limits
Requests are limited per client IP address, per minute:
| Endpoint | Limit per minute |
|---|---|
| Every endpoint (global) | RATE_LIMIT_MAX (600 by default) |
POST /api/v1/auth/login | 10 |
POST /api/v1/auth/refresh | 60 |
POST /api/v1/oauth/token | 30 |
| Sign-up and forgotten password | 5 |
| Password reset, accepting an invitation | 10 |
| Public form submissions and other public module routes | Set by each module |
Responses carry the standard X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers. Past the limit, the API returns 429 with a Retry-After header: wait that many seconds, then retry.
:::info For operators
Behind a load balancer or ingress, set TRUST_PROXY=true so limits apply to the real client address rather than the proxy's. See Deploying Modulix.
:::
Core endpoints
A selection of core endpoints useful to integrations. Module endpoints are documented in the OpenAPI document.
| Endpoint | Permission | Purpose |
|---|---|---|
GET /me/context | signed in | The caller, their organizations, enabled modules, effective permissions and menu |
GET /tenants | signed in | Organizations the person belongs to |
GET /users | core.users.read | Members of the organization |
GET /permissions | core.roles.read | Permission keys of the core and enabled modules |
GET /modules | core.modules.read | Modules, with their installation and enablement status |
GET /settings/tenant/:module, PUT /settings/tenant/:module | core.settings.read / core.settings.manage | The organization's settings of a module |
GET /audit | core.audit.read | Audit log entries |
GET /search?q= | signed in | Search across every record type you may read (at least 2 characters) |
GET /views?entity= | signed in | Saved views of a record list |
POST /data/:entity/import, POST /data/:entity/export | core.data.import / core.data.export and the record type's permission | Start a background import or export |
GET /jobs/:id, GET /jobs/:id/file | the person who started the job | Follow a job and download its file |
/api-keys, /oauth-clients | core.apikeys.manage | Manage API keys and OAuth clients |
/webhooks | core.webhooks.manage | Manage webhooks (see below) |
All paths are relative to /api/v1.
Webhooks
Outgoing webhooks send the organization's events to another system as they happen: a JSON POST to a URL you choose, signed with a secret, retried when it fails.
Webhooks are managed in Administration → Webhooks or through the API, with the permission core.webhooks.manage.
Events
| Event | Sent when | data |
|---|---|---|
<record type>.created | A record is created | { id, record } |
<record type>.updated | A record changes | { id, record, previous, changes } (changes lists the changed field names) |
<record type>.deleted | A record is deleted | { id, record } (the last snapshot, or null) |
user.added | A person joins the organization | { userId } |
user.removed | A person leaves the organization | { userId } |
module.enabled | A module is enabled for the organization | { moduleId } |
module.disabled | A module is disabled | { moduleId } |
ping | You select Send a test ping (or call the ping endpoint) | { webhook: { id, name } } |
Record types are those declared by the core and by the modules enabled for the organization, for example contacts.contact, contacts.company, sales.invoice, catalog.product, notes.note or core.user. GET /api/v1/webhooks/events lists every event the organization can subscribe to.
A subscription is a list of up to 100 patterns:
- an exact event:
contacts.contact.updated - a prefix ending in
.*:contacts.*(every Contacts event),sales.invoice.* *: every event
Creating a webhook
curl -X POST "$MODULIX_URL/api/v1/webhooks" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Tenant: brightline" \
-H "Content-Type: application/json" \
-d '{ "name": "CRM sync", "url": "https://hooks.example.com/modulix", "events": ["contacts.*", "sales.invoice.created"] }'
The response includes secret (whsec_…). It is returned only once; store it in the receiving system. Rotate it with POST /api/v1/webhooks/:id/secret; deliveries are signed with the new secret immediately.
| Endpoint | Purpose |
|---|---|
GET /webhooks/events | Events available to the organization |
GET /webhooks | Webhooks (without secrets) |
POST /webhooks | Create: { name, url, events, enabled? } |
PATCH /webhooks/:id | Change name, url, events or enabled |
DELETE /webhooks/:id | Delete the webhook and its delivery history |
POST /webhooks/:id/secret | Rotate the signing secret (returned once) |
POST /webhooks/:id/ping | Queue a ping event |
GET /webhooks/:id/deliveries?limit= | Latest deliveries (50 by default, up to 200) with status, response and errors |
POST /webhooks/deliveries/:id/redeliver | Send a delivery again, as a new delivery |
URL rules:
- The URL must be
httporhttps. In production, plainhttpURLs are refused; usehttps. - Private and local network addresses (such as
localhost,10.x,192.168.x) are refused when the delivery connects. Operators can allow them for development withWEBHOOKS_ALLOW_PRIVATE=true. - Redirects are not followed.
The delivery request
Each delivery is an HTTP POST with a JSON body:
POST /modulix HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
X-Modulix-Event: contacts.contact.updated
X-Modulix-Delivery: 0199a3d1-2f4b-7e88-b1c2-9d0e4f6a7b21
X-Modulix-Timestamp: 1792418531
X-Modulix-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
{
"id": "0199a3d1-2f4b-7e88-b1c2-9d0e4f6a7b21",
"event": "contacts.contact.updated",
"occurredAt": "2026-10-19T14:02:11.482Z",
"tenantId": "0199a1f0-…",
"data": {
"id": "0199a2b7-…",
"record": { "id": "0199a2b7-…", "firstName": "Maya", "lastName": "Chen", "email": "maya@example.com" },
"previous": { "id": "0199a2b7-…", "firstName": "Maya", "lastName": "Chen", "email": "maya.chen@example.com" },
"changes": ["email"]
}
}
| Header | Meaning |
|---|---|
X-Modulix-Event | The event name |
X-Modulix-Delivery | The delivery id (also id in the body) |
X-Modulix-Timestamp | When this attempt was signed, in Unix seconds |
X-Modulix-Signature | sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with the webhook's secret |
Verifying the signature
To check that a delivery comes from Modulix and was not altered:
- Read the raw request body, before any JSON parsing.
- Build the string
<X-Modulix-Timestamp>.<raw body>. - Compute the HMAC-SHA256 of that string with the whole webhook secret (including the
whsec_prefix) as the key, and hex-encode it. - Compare
sha256=<your hex digest>withX-Modulix-Signatureusing a constant-time comparison. - Optionally, reject timestamps older than a few minutes to limit replays.
Node.js (Express):
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.MODULIX_WEBHOOK_SECRET // whsec_…
app.post('/modulix', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Modulix-Timestamp') ?? ''
const signature = req.get('X-Modulix-Signature') ?? ''
const expected =
'sha256=' + crypto.createHmac('sha256', SECRET).update(`${timestamp}.${req.body}`).digest('hex')
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300
if (!valid || !fresh) return res.sendStatus(401)
const event = JSON.parse(req.body.toString('utf8'))
// Queue the work, then answer quickly.
res.sendStatus(204)
})
Python (Flask):
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["MODULIX_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/modulix")
def modulix():
timestamp = request.headers.get("X-Modulix-Timestamp", "")
signature = request.headers.get("X-Modulix-Signature", "")
body = request.get_data() # raw bytes
expected = "sha256=" + hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected) or abs(time.time() - int(timestamp or 0)) > 300:
abort(401)
event = request.get_json()
return "", 204
Retries and delivery status
- A delivery succeeds when your endpoint answers with a
2xxstatus within 10 seconds. Any other status, a redirect, a timeout or a connection error is a failure. - A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (six attempts in all). After the last failure it is marked failed.
- Each attempt is logged with its response status, the first 1,000 characters of the response body, the error and the duration. Open the webhook's Deliveries in Administration → Webhooks, or call
GET /api/v1/webhooks/:id/deliveries. - Delivery statuses:
queued(waiting for its first or next attempt),sending,sent,failed. - A delivery whose webhook has been paused or deleted by the time it is sent fails without being retried.
- After fixing your endpoint, redeliver a failed delivery from the list or with
POST /api/v1/webhooks/deliveries/:id/redeliver. A redelivery is a new delivery with a new id.
:::tip Building a reliable receiver
- Answer fast (within 10 seconds) and do the work asynchronously.
- Deliveries can occasionally arrive more than once and out of order. Use
X-Modulix-Deliveryto ignore duplicates, andoccurredAtor the record'supdatedAtto ignore stale updates. - Return a non-
2xxstatus only when you want Modulix to retry. :::