Skip to main content

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).

WhatPath
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 4000 by 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
note

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.

CredentialSend it asBest for
API keyX-Api-Key: mx_… or Authorization: Bearer mx_…Scripts and integrations
OAuth client tokenAuthorization: Bearer <token> from POST /api/v1/oauth/tokenServer-to-server integrations that prefer short-lived tokens
User access token (JWT)Authorization: Bearer <token> from POST /api/v1/auth/loginThe 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 get 401.
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/refresh with { "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/logout revokes the refresh token.
tip

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:

CredentialHow the organization is chosen
API key or OAuth clientAlways 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 token1. 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 400 with Tenant 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 404 or 403.

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 use application/octet-stream bodies on the endpoints that accept files.
  • Ids are UUIDs (version 7).
  • Dates are YYYY-MM-DD strings; 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.
  • POST returns the created record; PATCH takes only the fields to change and returns the updated record; DELETE returns 204 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"
}
MemberMeaning
titleThe HTTP status in words (Not found, Conflict, Validation failed, ...)
statusThe HTTP status code
detailA human-readable explanation, in English. Optional
errorsField problems, for validation errors: [{ path, message, code }]
other membersSome 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​

StatusWhen
400 Bad RequestMalformed request, or no organization could be resolved
401 UnauthorizedMissing, invalid, expired or revoked credentials (Authentication required, Invalid API key, Invalid or expired access token)
403 ForbiddenMissing permission, not a member of the organization, or an API key used with another organization's X-Tenant
404 Not FoundThe record does not exist or is not visible to you, the organization is unknown, or the module is not enabled for the organization
409 ConflictThe 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 LargeAn uploaded file is over the limit (JSON bodies are limited to 5 MB)
422 Unprocessable EntityValidation failed (see above)
429 Too Many RequestsRate limit exceeded
500 Internal Server ErrorAn 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:

ParameterMeaning
pagePage number, starting at 1 (default 1)
pageSizeRecords per page (default 25; at most 200 for most lists, 100 for contacts and companies)
qText search across the record type's searchable fields (and custom fields, where supported)
sortField to sort by (sortable fields of the record type, createdAt or updatedAt)
dirasc or desc
filterA JSON filter (see below), URL-encoded
record-specific parametersEquality 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 typeOperators
Text (short)contains, notContains, eq, neq, startsWith, empty, notEmpty
Text (long)contains, notContains, empty, notEmpty
Numbereq, neq, gt, gte, lt, lte, between, empty, notEmpty
Dateeq, gt, gte, lt, lte, between, empty, notEmpty
Yes/noisTrue, isFalse
Choice, listin, 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:

EndpointLimit per minute
Every endpoint (global)RATE_LIMIT_MAX (600 by default)
POST /api/v1/auth/login10
POST /api/v1/auth/refresh60
POST /api/v1/oauth/token30
Sign-up and forgotten password5
Password reset, accepting an invitation10
Public form submissions and other public module routesSet 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.

EndpointPermissionPurpose
GET /me/contextsigned inThe caller, their organizations, enabled modules, effective permissions and menu
GET /tenantssigned inOrganizations the person belongs to
GET /userscore.users.readMembers of the organization
GET /permissionscore.roles.readPermission keys of the core and enabled modules
GET /modulescore.modules.readModules, with their installation and enablement status
GET /settings/tenant/:module, PUT /settings/tenant/:modulecore.settings.read / core.settings.manageThe organization's settings of a module
GET /auditcore.audit.readAudit log entries
GET /search?q=signed inSearch across every record type you may read (at least 2 characters)
GET /views?entity=signed inSaved views of a record list
POST /data/:entity/import, POST /data/:entity/exportcore.data.import / core.data.export and the record type's permissionStart a background import or export
GET /jobs/:id, GET /jobs/:id/filethe person who started the jobFollow a job and download its file
/api-keys, /oauth-clientscore.apikeys.manageManage API keys and OAuth clients
/webhookscore.webhooks.manageManage 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​

EventSent whendata
<record type>.createdA record is created{ id, record }
<record type>.updatedA record changes{ id, record, previous, changes } (changes lists the changed field names)
<record type>.deletedA record is deleted{ id, record } (the last snapshot, or null)
user.addedA person joins the organization{ userId }
user.removedA person leaves the organization{ userId }
module.enabledA module is enabled for the organization{ moduleId }
module.disabledA module is disabled{ moduleId }
pingYou 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.

EndpointPurpose
GET /webhooks/eventsEvents available to the organization
GET /webhooksWebhooks (without secrets)
POST /webhooksCreate: { name, url, events, enabled? }
PATCH /webhooks/:idChange name, url, events or enabled
DELETE /webhooks/:idDelete the webhook and its delivery history
POST /webhooks/:id/secretRotate the signing secret (returned once)
POST /webhooks/:id/pingQueue 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/redeliverSend a delivery again, as a new delivery

URL rules:

  • The URL must be http or https. In production, plain http URLs are refused; use https.
  • 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 with WEBHOOKS_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"]
}
}
HeaderMeaning
X-Modulix-EventThe event name
X-Modulix-DeliveryThe delivery id (also id in the body)
X-Modulix-TimestampWhen this attempt was signed, in Unix seconds
X-Modulix-Signaturesha256= 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:

  1. Read the raw request body, before any JSON parsing.
  2. Build the string <X-Modulix-Timestamp>.<raw body>.
  3. Compute the HMAC-SHA256 of that string with the whole webhook secret (including the whsec_ prefix) as the key, and hex-encode it.
  4. Compare sha256=<your hex digest> with X-Modulix-Signature using a constant-time comparison.
  5. 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 2xx status 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-Delivery to ignore duplicates, and occurredAt or the record's updatedAt to ignore stale updates.
  • Return a non-2xx status only when you want Modulix to retry. :::