HTTP API
For client developers: every endpoint, query parameter, QUERY body, response shape and status code.
Endpoints#
Every createRouter router serves the same six routes. Examples use /api/products; substitute your mount path.
| Method | Path | Body | Success |
|---|---|---|---|
| GET | /api/products | — | 200, list |
| QUERY | /api/products | JSON read parameters | 200, list |
| POST | /api/products | JSON record, or multipart/form-data on upload routers | 201, record |
| GET | /api/products/:id | — | 200, record |
| PATCH | /api/products/:id | JSON fields to change (others are kept) | 200, record |
| DELETE | /api/products/:id | — | 200, message |
- Auth: protected routes need
Authorization: Bearer <token>. - Allowlists: every parameter below only works on fields the server allows. Ask the backend team which fields are filterable, searchable, sortable and writable — or read them off the router's options.
- No PUT: updates are
PATCHand change only the fields you send.
Filtering#
| Filter | Example | Matches | Field must be in |
|---|---|---|---|
| Equals | ?category=chairs | category is "chairs" | query |
| Any of | ?category=chairs&category=tables | chairs or tables | query |
| Several fields | ?category=chairs&inStock=true | both (AND) | query |
| Range | ?rangeField=price&range=50-200 | 50 ≤ price ≤ 200 | filterableFields |
| Open range | ?rangeField=price&range=50- · range=-200 | price ≥ 50 · price ≤ 200 | filterableFields |
| Compare | ?compareField=price&compareOperator=gt&compareValue=100 | price > 100 | filterableFields |
compareOperator is one of eq (default), ne, gt, gte, lt, lte.
- An equality parameter for a field not in
queryis ignored. A range or compare on a field not infilterableFieldsis a 400 naming it. - Range bounds and numeric-looking compare values are treated as numbers. Ranges split on
-, so negative bounds needcompareFieldor QUERY. - One range and one compare per request. Need more? Use QUERY.
- Object-shaped values such as
?price[$ne]=0are a 400 — that's how operator injection gets in.
Search#
GET /api/products?s=oak
GET /api/products?search=oak # same thing
- Case-insensitive, matches anywhere in the text:
oakfinds "Oak chair" and "Cloak". - Searches every field in the server's
searchlist — a match in any one counts. Combined with filters by AND. - Matched literally:
.*and(are just characters. - Up to 128 characters (the server's
maxSearchLength); longer is a 400.
Sorting#
GET /api/products?sort=price # ascending
GET /api/products?sort=-price # descending
- One field, from the server's
sortableFields; any other is a 400. - Without
sort, the server's default order applies. - Several sort keys: use QUERY —
"sort": ["-price", "name"].
Pagination#
The server uses one of two modes. Offset is the default.
Offset: page numbers#
GET /api/products?page=2&pageSize=20
{
"success": true,
"data": [ … 20 records … ],
"pagination": { "currentPage": 2, "pageSize": 20, "totalPages": 7, "totalRecords": 132 }
}
| You send | You get |
|---|---|
no page | An unpaginated list, no pagination block, capped at the server's maxLimit (100 by default). |
page=1 | 50 records (the default page size). |
page=1&pageSize=5000 | Clamped to maxLimit. |
page=abc or page=-3 | Treated as page 1. |
If the server skips counting, pagination carries hasMore instead of totalPages and totalRecords.
Cursor: follow nextCursor#
When the server uses pagination: 'cursor', there are no page numbers. Request the first page with no cursor, then pass back nextCursor until hasMore is false:
GET /api/events?pageSize=50
→ { "data": [ … ], "pagination": { "pageSize": 50, "hasMore": true, "nextCursor": "eyJpIjoi…" } }
GET /api/events?pageSize=50&cursor=eyJpIjoi…
→ … until "hasMore": false and "nextCursor": null
- Keep the same
sortand filters for the whole scan; changingsortmid-scan is a 400. - If the record a cursor points at was deleted, you get a 400 — restart from the first page.
- Sending
pageto a cursor server, orcursorto an offset server, is a 400.
Putting it together#
# Chairs in stock, 50–200, matching "oak", most expensive first, page 2 of 20
GET /api/products?category=chairs&inStock=true&rangeField=price&range=50-200&s=oak&sort=-price&page=2&pageSize=20
const params = new URLSearchParams({
category: 'chairs', inStock: 'true',
rangeField: 'price', range: '50-200',
s: 'oak', sort: '-price', page: '2', pageSize: '20',
});
const res = await fetch(`/api/products?${params}`);
const { success, data, pagination, error } = await res.json();
if (!success) throw new Error(error);
QUERY: reads with a JSON body#
QUERY (RFC 10008) is the same read as GET /, with the parameters in a JSON body. Like GET it never writes. Use it when a URL won't do: a long list of ids, several conditions per field, a multi-key sort, or filters you'd rather keep out of access logs. The response is identical to the equivalent GET.
const res = await fetch('/api/products', {
method: 'QUERY',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: {
category: ['chairs', 'tables'], // list → any of
price: { gte: 50, lte: 200 }, // object → operators
inStock: true, // scalar → equals
},
search: 'oak',
sort: ['-price', 'name'],
page: 1,
pageSize: 20,
}),
});
Body keys#
| Key | Type | Meaning |
|---|---|---|
filter | object | Field → condition. Every field must be in filterableFields — being in query alone isn't enough if filterableFields is set. |
search | string | Same as ?s=. |
sort | string · string[] | "-price", or up to five keys, each in sortableFields. |
page | integer ≥ 1 | Paginated response. Not with limit. |
pageSize | integer ≥ 1 | Per page, clamped to maxLimit. Alone, implies page: 1. |
limit | integer ≥ 1 | Cap an unpaginated read, clamped to maxLimit. |
cursor | string | Next page, on cursor-mode servers. |
Every key is optional; an empty body is a plain list. Unknown keys are a 400, so a "filters" typo never silently returns the whole collection. page, pageSize and limit must be JSON numbers, not strings.
Operators#
| Operator | Takes | Matches |
|---|---|---|
eq · ne | a value | Equal · not equal |
gt · gte | a value | Greater than · or equal |
lt · lte | a value | Less than · or equal |
in · nin | 1–100 values | Any of · none of |
Operators are spelled without $. Values are strings, numbers, booleans or null, cast by the schema — "gte": "50" on a Number field works. Nested objects, empty conditions and unknown operators are a 400.
The same read, both ways#
| Query string | QUERY body |
|---|---|
?category=65af… | { "filter": { "category": "65af…" } } |
?tag=sale&tag=new | { "filter": { "tag": ["sale", "new"] } } |
?rangeField=price&range=10-100 | { "filter": { "price": { "gte": 10, "lte": 100 } } } |
?compareField=price&compareOperator=gt&compareValue=50 | { "filter": { "price": { "gt": 50 } } } |
?s=chair | { "search": "chair" } |
?sort=-price | { "sort": "-price" } |
| — not expressible | { "sort": ["-price", "name"] } |
?page=2&pageSize=20 | { "page": 2, "pageSize": 20 } |
Sending it#
Browsers and Node's fetch send QUERY as written — see the example above.
curl -X QUERY http://localhost:3000/api/products \
-H 'Content-Type: application/json' \
-d '{"filter":{"price":{"gte":50}},"sort":"-price","pageSize":20}'
const { data } = await axios.request({
url: '/api/products',
method: 'QUERY',
headers: { 'Content-Type': 'application/json' }, // set it: some versions omit it for unknown methods
data: { filter: { tag: ['sale', 'new'] }, sort: ['-price', 'name'] },
});
Is it available?#
OPTIONS on the collection answers:
OPTIONS /api/products HTTP/1.1
HTTP/1.1 200 OK
Allow: GET, HEAD, POST, QUERY
Accept-Query: application/json
No QUERY in Allow means the server turned it off, runs Node older than 22.2, or a proxy stripped it. Many proxies, CDNs and API gateways drop methods they don't recognise, so test through your real edge and keep GET as the fallback. Don't put a URL-keyed shared cache in front of QUERY — every request to a collection has the same URL.
What's refused#
| Request | Status | Why |
|---|---|---|
{ "filter": { "secretToken": "x" } } | 400 | Not in filterableFields. |
{ "filter": { "$where": "…" } } | 400 | $-prefixed or dotted keys are never field names. |
{ "filter": { "price": { "$gte": 1 } } } | 400 | Spell it gte. |
{ "sort": "createdBy" } | 400 | Not in sortableFields. |
{ "filters": { … } } | 400 | Unknown key. |
{ "page": "2" } | 400 | Must be a number. |
Body sent as text/plain, or no JSON parser on the server | 415 | The body must be parsed JSON. |
QUERY /api/products/65af… | 404 | Collection only. Use GET /:id. |
Read, create, update, delete#
// Read one
await fetch('/api/products/65af…');
// Create — only fields the server allows are saved; others are silently dropped
await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Oak chair', price: 120 }),
});
// Update — send only what changes
await fetch('/api/products/65af…', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price: 99 }),
});
// Delete
await fetch('/api/products/65af…', { method: 'DELETE' });
Sending files#
On routers with uploads, send multipart/form-data: ordinary fields as text, files under the field names the server configured. Don't set Content-Type yourself — the browser adds the boundary.
const form = new FormData();
form.append('name', 'Chair');
form.append('price', '120');
form.append('cover', coverInput.files[0]);
for (const file of galleryInput.files) form.append('gallery', file);
const res = await fetch('/api/products', { method: 'POST', body: form });
const { data } = await res.json();
data.cover; // "https://…/products/1726…-51234.jpg"
data.gallery; // ["https://…", "https://…"]
curl -X POST localhost:3000/api/products \
-F name=Chair -F price=120 \
-F cover=@cover.jpg \
-F gallery=@side.jpg -F gallery=@back.jpg
Responses#
success is always present; branch on it.
// GET /api/products
{ "success": true, "data": [ { "_id": "65af…", "name": "Laptop", "price": 1299 } ] }
// Offset, counted
"pagination": { "currentPage": 2, "pageSize": 20, "totalPages": 7, "totalRecords": 132 }
// Offset, countStrategy: 'none'
"pagination": { "currentPage": 2, "pageSize": 20, "hasMore": true }
// Cursor
"pagination": { "pageSize": 20, "hasMore": true, "nextCursor": "eyJpIjoi…" }
// GET /:id · POST / (201) · PATCH /:id
{ "success": true, "data": { "_id": "65af…", "name": "Laptop", "price": 1299 } }
// DELETE /:id
{ "success": true, "message": "Item successfully deleted." }
{ "success": false, "error": "Entry not found." }
// A rejected write with per-field detail
{ "success": false, "error": "Check the submitted values.", "fields": { "price": "must not be negative" } }
// Through errorHandler (e.g. a 500)
{ "success": false, "error": "Internal Server Error", "requestId": "0f2a5b8c-…" }
fieldsappears only on per-field failures: a validation error, a schema validation failure or a duplicate key. Never on a 5xx.requestIdappears on errors that pass through the server'serrorHandler. Quote it when reporting a problem — it points to the log line.stackappears only when the server runs withNODE_ENV=development.
Status codes#
| Code | When | Who fixes it |
|---|---|---|
200 · 201 | Success · record created. | — |
400 | Malformed id; a field that isn't filterable or sortable; a bad filter value; a search term over the limit; a body with no writable fields; a failed validation; a rejected file type or upload limit; an invalid QUERY body or cursor. | The client |
401 · 403 | Not signed in · not allowed. From the server's guards. | The client |
404 | No record with that id. | The client |
405 | Method refused by a read-only guard (if the server uses one). | The client |
409 | A unique field already has that value — "Duplicate value for: email." | The client |
415 | A QUERY body that isn't JSON. | The client, or the server if no JSON parser is mounted |
500 | Anything unexpected. Details are logged, never sent. | The server |
503 | An upload route with S3 not configured, or an email/SMS sender that failed. | The server |