Generated (data-plane) API reference
Complete reference for every generated tenant API — routes, query parameters, filter operators, request/response bodies, type mapping, authentication, error catalogue, and rate limiting.
Every RestWard tenant API is a generated REST API that exposes your database tables as RESTful resources. This page documents the exact shape, parameters, operators, authentication, errors and rate limits that every generated API supports, independent of your schema.
What changed since issue #165 (2026-09-28) See the discrepancy list at the bottom of this page.
Base URL & schema
| Aspect | Value |
|---|---|
| Base path | /api/ |
| Schema segment | The database schema name: PostgreSQL public, SQL Server dbo, MySQL the database name, Oracle the connecting user (upper-cased), Db2 the current user's schema |
| Table segment | The table name (case-sensitive as defined in the database) |
Example: GET /api/public/artist lists rows from the artist table in PostgreSQL's public schema.
Routes per table
Every table is exposed with the following standard routes.
| Method | Route | Operation | Description |
|---|---|---|---|
GET |
/api/{schema}/{table} |
List | List rows with filtering, sorting, pagination and field projection |
GET |
/api/{schema}/{table}/{id} |
Get by ID | Retrieve one row by its primary key |
POST |
/api/{schema}/{table} |
Create | Insert a new row |
PUT |
/api/{schema}/{table}/{id} |
Update | Full update: replace the supplied columns; unknown, key and generated columns are silently ignored |
PATCH |
/api/{schema}/{table}/{id} |
Partial update | Strict partial update: update exactly the supplied columns; empty body, unknown column, primary-key column or generated column is a 400 |
DELETE |
/api/{schema}/{table}/{id} |
Delete | Delete one row by its primary key |
Composite primary keys
Tables with a multi-column primary key use a comma-separated list of values in the {id} route segment, one value per PK column in the order defined by the table's primary key columns.
Example: A table order_items with PK (order_id, line_number):
- Route:
GET /api/public/order_items/42,3 - Returns the row where
order_id = 42ANDline_number = 3
If the number of comma-separated segments does not match the number of PK columns, the request returns 400 with a clear message naming the table and the expected number of values.
Views
Views are not exposed. Every schema reflector returns base tables only, filtering by table_type = 'BASE TABLE'. Only base tables become endpoints.
Query parameters
All list (GET /api/{schema}/{table}) and get-by-ID (GET /api/{schema}/{table}/{id}) endpoints accept the following query parameters.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 100 |
Number of rows to return; clamped to 1–1000 |
offset |
integer | 0 |
Number of rows to skip; negative values count as 0 |
sort |
string | Primary key ascending | Comma-separated column names; prefix with - for descending (e.g., sort=artist_id,-title) |
fields |
string | All columns | Comma-separated column names to return (projection); unknown or empty is a 400 |
count |
boolean | false |
true includes the total number of matching rows in the response body and the X-Total-Count header |
Filter parameters
Filters are specified as query parameters using one of two syntaxes:
- Simple equality:
column=value(impliciteqoperator) - Explicit operator:
column[op]=valuewhereopis one of the supported operators
Supported filter operators (8):
| Operator | Syntax | SQL equivalent | Notes |
|---|---|---|---|
eq |
column[eq]=value or column=value |
column = value |
Equality |
neq |
column[neq]=value |
column <> value |
Not equal |
gt |
column[gt]=value |
column > value |
Greater than |
gte |
column[gte]=value |
column >= value |
Greater than or equal |
lt |
column[lt]=value |
column < value |
Less than |
lte |
column[lte]=value |
column <= value |
Less than or equal |
like |
column[like]=value |
column LIKE value |
SQL pattern: % matches any text, _ matches one character |
in |
column[in]=value |
column IN (value1,value2,...) |
Comma-separated list of values |
Combining filters: Multiple filter parameters are combined with AND logic.
Unknown columns/operators: An unknown filter column or operator is not silently dropped — it returns a 400 error naming the parameter and the problem (#151 resolved).
Examples:
GET /api/public/artist?name=AC/DC— equality filterGET /api/public/album?artist_id[gt]=5&artist_id[lte]=10— range filterGET /api/public/track?title[like]=%Rock%— pattern matchGET /api/public/invoice?status[in]=paid,pending— IN filter
Request & response bodies
Response: List rows
Without count=true:
[
{ "column1": "value1", "column2": 123 },
{ "column1": "value2", "column2": 456 }
]With count=true:
{
"total": 42,
"items": [
{ "column1": "value1", "column2": 123 },
{ "column1": "value2", "column2": 456 }
]
}Headers with count=true:
X-Total-Count: 42— the total number of matching rows
Response: Get by ID
{ "column1": "value1", "column2": 123 }Returns 404 if no row matches the primary key.
Request: Create
{ "column1": "value1", "column2": 123 }- Required: Columns that are
NOT NULLand not auto-increment/generated - Ignored: Server-generated columns (identity keys, generated values), unknown columns
- Response:
201 Createdwith{ "id": <value> }whereidis the primary key value(s)
Request: Update (PUT)
{ "column1": "new_value1", "column2": 456 }- Behavior: Updates the supplied columns; unknown, primary-key and generated columns are silently ignored
- Empty body: Returns
400if the body contains no updatable columns - Response:
204 No Contenton success,404if no row matches the primary key
Request: Partial update (PATCH)
{ "column1": "new_value1" }- Behavior: Strict partial update — updates exactly the supplied columns
- Validation: Empty body, unknown column, primary-key column or generated column returns
400naming the column - Response:
204 No Contenton success,404if no row matches the primary key
Request: Delete
No request body. Returns 204 No Content on success, 404 if no row matches the primary key.
Type mapping per provider
Column types in responses and request bodies are mapped from the database's native types to JSON types as follows. All providers use the common mapping as the baseline; provider-specific overrides are noted.
Common type mapping (PostgreSQL, SQL Server, MySQL, Oracle, Db2)
| Native type(s) | JSON type | OpenAPI type | Notes |
|---|---|---|---|
integer, int, int4, serial, smallint, int2, tinyint |
number | integer |
|
bigint, int8, bigserial |
number | integer |
|
real, float4, float |
number | number |
|
double precision, float8, double, decimal, numeric, money |
number | number |
|
boolean, bool, bit |
boolean | boolean |
|
timestamp, timestamptz, timestamp without time zone, timestamp with time zone, datetime, datetime2, date |
string | string |
ISO 8601 format (e.g., 2024-01-15T10:30:00Z) |
| All other types | string | string |
Includes text, varchar, char, nvarchar, nchar, clob, blob, etc. |
Null handling
- In responses:
nulldatabase values are serialized as JSONnull - In requests:
nullJSON values are passed asNULLto the database; omitting a column is different from sendingnull - NOT NULL columns: Omitting a NOT NULL column on
POSTreturns422; sendingnullfor a NOT NULL column also returns422
Timestamp handling
- Format: ISO 8601 strings (e.g.,
2024-01-15T10:30:00Z,2024-01-15T10:30:00+01:00) - Time zone: Timestamps without time zone information are treated as UTC; timestamps with time zone are preserved
- Precision: Sub-second precision is preserved where the database supports it
Authentication
Every generated API supports the authentication methods enabled for its organization. The runtime checks credentials in the following order and uses the first valid one.
Supported authentication methods
| Method | Header | Description |
|---|---|---|
| API Key | X-API-Key: <key> |
Simple key-based authentication; key is hashed with SHA-256 for storage and lookup |
| JWT Bearer | Authorization: Bearer <token> |
JSON Web Token; the token's org_id claim must match the API's organization |
| mTLS | Client certificate | Mutual TLS; the client certificate's thumbprint is used as the credential |
Authentication flow
- The route's required authentication type is determined by:
- The endpoint configuration from the dashboard (#149) — if set, only that method is accepted
- Otherwise, the route's static metadata (legacy default: API Key)
- If the route is Public, no authentication is required
- For authenticated routes, the middleware tries each enabled method in order (API Key, JWT, mTLS) and uses the first valid credential
- If no valid credential is found, the request returns
401 Unauthorized - If the credential is valid but the caller lacks permission for the operation, the request returns
403 Forbidden
Public endpoints
Endpoints can be configured as Public in the dashboard, which means:
- No authentication is required
- No
401or403responses are possible - Rate limiting still applies (endpoint limit and platform limits)
- No API key quota is applied (only the endpoint's own per-caller limit)
Error catalogue
Every error response has a JSON body with at least an error field. Additional fields may be present depending on the error type.
Error response body
{
"error": "Human-readable error message"
}Additional fields that may be present:
"column": "column_name"— the column that caused the error"constraint": "constraint_name"— the database constraint that was violated"parameter": "param_name"— the query parameter that caused the error
HTTP status codes
| Code | Title | When returned | Body shape |
|---|---|---|---|
400 |
Bad Request | Invalid query parameters, malformed JSON, unknown columns/operators, duplicate columns in body, empty PATCH body, invalid primary key format | {error, parameter?, column?} |
401 |
Unauthorized | No valid authentication credential presented | {error} |
403 |
Forbidden | Valid credential but insufficient permissions | {error} |
404 |
Not Found | No row matches the primary key (GET by ID, UPDATE, DELETE) | {error} |
405 |
Method Not Allowed | HTTP method not mapped to a route (e.g., DELETE /api/{schema}/{table} on the collection route, or PATCH/PUT/DELETE without {id}) |
{error} |
409 |
Conflict | Unique constraint violation (create/update), foreign key violation (delete) | {error, constraint?, column?} |
413 |
Payload Too Large | Request body exceeds 1MB limit | {error} |
422 |
Unprocessable Entity | Value coercion failure, NOT NULL violation, check constraint violation, foreign key violation (create/update) | {error, constraint?, column?} |
429 |
Too Many Requests | Rate limit exceeded | {error} + rate limit headers |
500 |
Internal Server Error | Database error (generic message, logged with details) | {error} |
Database error classification
Database errors are classified by the provider's exception classifier and mapped to appropriate HTTP status codes:
| Database error kind | HTTP status | Typical cause |
|---|---|---|
UniqueViolation |
409 |
Duplicate unique key/constraint |
ForeignKeyViolation (on DELETE) |
409 |
Row is still referenced |
ForeignKeyViolation (on CREATE/UPDATE) |
422 |
Referenced row does not exist |
NotNullViolation |
422 |
Required column is missing or null |
CheckViolation |
422 |
Value violates a check constraint |
| Other | 422 |
Value is not valid (too long, out of range, wrong format) |
Rate limiting
Every generated API enforces four independent rate limits in order. A request is rejected as soon as it exceeds the first limit it encounters; it never consumes quota from limits checked later.
| Limit | Scope | Window | Key | When applied |
|---|---|---|---|---|
| Platform global | Entire deployment | Configurable (default 60s) | ratelimit:platform:global |
Every request to every tenant/API |
| Platform tenant | One organization | Configurable (default 60s) | ratelimit:platform:tenant:{orgId} |
Every request to any API of that org |
| Caller quota | One authenticated caller | 1 minute | Per API key, JWT subject, or mTLS cert | All routes of the API |
| Endpoint limit | One route + caller | 1 minute | Per endpoint path + caller | That specific endpoint |
Platform limits (#188) are fleet-wide and checked first so a refused request never consumes caller or endpoint quota.
Rate limit headers on every response
Every successful response and every 429 rejection carries the three standard rate-limit headers for the quota closest to running out:
X-RateLimit-Limit— the quota ceiling for that windowX-RateLimit-Remaining— requests left in the current windowX-RateLimit-Reset— seconds until the window resets
On a 429 response only, you also receive:
Retry-After— seconds to wait before retrying (same value asX-RateLimit-Reset)X-RateLimit-Scope— which limit tripped:global,tenant,caller, orendpoint
Example 429 response:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 42
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 42
X-RateLimit-Scope: caller
{"error": "Rate limit exceeded. Try again later."}Caller quota defaults
- API keys: Each key has its own
RateLimitPerMinute(default: 60) - JWT/mTLS: The authentication method's configured quota, or 60 if not set
- Anonymous (Public endpoints): No caller quota; only platform and endpoint limits apply
Backing store
When a distributed store (Redis) is configured, counters are fleet-wide across all API runtime replicas. If the store is unavailable or not configured, the middleware fails open to in-process counters so requests are never rejected due to store unavailability; the quota then applies per replica until the store recovers.
OpenAPI documents
Every generated API serves two OpenAPI 3.0.3 documents:
| Path | Document | Description |
|---|---|---|
/openapi.json |
Plain | Describes the API surface exactly: every served operation with stable operationId, list parameters, request schemas, every status code, per-operation security, and x-restward-* extensions |
/openapi-enhanced.json |
Enhanced | The plain document plus organization documentation settings: info.description, contact, per-property examples, and authored request/response examples |
What the documents contain
Paths:
- One path per table:
/api/{schema}/{table} - One path per table with
{id}:/api/{schema}/{table}/{id} - Custom endpoints appear as additional paths
Operations per path:
GET— list (with parameters) and get-by-IDPOST— createPUT— updatePATCH— partial updateDELETE— delete
Parameters (list operation):
limit(integer, default 100, min 1, max 1000)offset(integer, default 0, min 0)sort(string, comma-separated columns with optional-prefix)fields(string, comma-separated column names)count(boolean, default false)- One filter parameter per column (equality) plus operator syntax parameters
Schemas:
{schema}_{table}— the row schema (all columns, read-only for key/generated columns){schema}_{table}_create— create request body (writable columns, NOT NULL required){schema}_{table}_update— update/PATCH request body (updatable columns, minProperties: 1){schema}_{table}_page— list response with count (total + items){schema}_{table}_created— create response (id object)Error— shared error schema
Security schemes:
ApiKeyAuth—X-API-KeyheaderBearerAuth—Authorization: Bearerheader (only if JWT is enabled)
Extensions:
x-restward-filter-operators— the 8 supported operatorsx-restward-rate-limit-per-minute— endpoint-specific rate limitx-restward-cache-ttl-seconds— endpoint-specific cache TTL
Responses:
- All operations document:
200,201,204,400,401,403,404,405,409,413,422,429,500 - Rate limit headers are documented on all successful responses
Examples
See the examples section for practical, language-specific examples:
- Authentication — API key, JWT, mTLS examples
- List & read — limit, offset, sort, fields, count
- Filtering — all 8 filter operators with examples
- Create, update, delete — POST, PUT, PATCH, DELETE
- Pagination & sync — walking large tables
- Errors & rate limits — error responses and 429 handling
- OpenAPI client generation — generating SDKs from the OpenAPI spec
Issue #165 vs today: discrepancy list
The original issue #165 (2026-09-28) contained several statements that have since changed in the codebase. This reference documents the current behavior as implemented in the code.
| Issue statement | Current behavior | Code location |
|---|---|---|
| "no rate-limit headers today (#152)" | Rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) are present on every response (success and 429) for the closest quota |
ApiKeyRateLimitingMiddleware.WriteHeaders, ApiKeyRateLimitingMiddleware.WriteRateLimitedAsync |
Retry-After and X-RateLimit-Scope are present on 429 responses |
ApiKeyRateLimitingMiddleware.WriteRateLimitedAsync |
|
| Four independent limits are enforced (platform global, platform tenant, caller quota, endpoint limit) | ApiKeyRateLimitingMiddleware.InvokeAsync |
|
"fields is accepted but ignored (#153)" |
fields is fully implemented: comma-separated column names for projection; unknown or empty is a 400 |
FilterParser.ParseFields |
count=true is fully implemented: returns {total, items} and X-Total-Count header |
FilterParser.ParseCount, DynamicQueryHandler.HandleListAsync |
|
| "unknown columns/operators are silently dropped (#151)" | Unknown filter columns or operators return 400 naming the parameter |
FilterParser.ParseFilters (throws QueryParameterException) |
| "PUT is partial update, no PATCH" | PATCH is implemented (#153): strict partial update; PUT is full update (unknown/key/generated columns ignored) | DynamicRouteRegistrar.StandardRoutes, DynamicQueryHandler.HandlePatchAsync |
| "eight filter operators" | Confirmed: eq, neq, gt, gte, lt, lte, like, in |
FilterParser.ParseFilters, DynamicOpenApiGenerator.FilterOperators |
| "no rate-limit headers today" | Rate limit headers are documented in the OpenAPI spec | DynamicOpenApiGenerator.AddRateLimitHeaders |
See also
- API keys & tenant API auth — Creating and managing API keys
- API Explorer & docs — Interactive testing of endpoints
- Rate limiting — Detailed guide on rate limiting configuration and handling