API Conventions
The one-page contract every Signals v1 endpoint follows — pagination, errors, includes, sparse fieldsets, idempotency, async, deprecation, and events.
Overview
Every endpoint under /api/v1/ follows the same conventions. Learn them once and
they apply everywhere. This page is the canonical checklist; individual resource
pages only document what is specific to them.
Base URL & versioning
All endpoints are prefixed with /api/v1/. Version 1 is additive-only — see
Versioning & Deprecation. Discover changes at
GET /api/v1/changelog.
Authentication
Requests authenticate with a Sanctum bearer token:
curl -H "Authorization: Bearer {token}" {app-url}/api/v1/accounts
Tokens carry abilities in resource:action form (e.g. accounts:read,
invoices:write). A missing ability returns 403; a missing/invalid token
returns 401.
Response envelopes
A single resource is wrapped in a singular key; a collection in a plural
key with a meta block:
{ "account": { "id": 1 } }
{
"accounts": [ { "id": 1 } ],
"meta": { "total": 47, "per_page": 20, "page": 1 }
}
Pagination
Offset-based via ?page= and ?per_page=. The meta block returns total,
per_page, and page.
Filtering (Ransack-compatible)
Filter with ?q[field_predicate]=value. Supported predicates include _eq,
_not_eq, _lt, _lteq, _gt, _gteq, _cont, _not_cont, _start,
_end, _in, _not_in, _null, _not_null, _present, _blank, _true,
_false. Custom fields are addressable as ?q[cf.field_name_eq]=value.
Ignored filters and sorts
Unknown predicates, unknown fields, and fields outside an endpoint's filterable
allow-list are skipped rather than rejected — a 422 here would break
Ransack/RMS parity and invalidate saved live filters whose stored filters
outlive a schema change.
So that a dropped predicate can never be mistaken for an applied one, every
endpoint that accepts q[] echoes what it discarded:
{
"catalogue_items": [],
"meta": { "total": 0, "per_page": 20, "page": 1, "ignored_filters": ["colour_eq"] }
}
meta.ignored_filters— filter keys that matched no filterable field.meta.ignored_sorts— sort fields outside the sortable allow-list.
Both keys are absent when everything you sent was applied; neither is ever
an empty array. The same contract covers GET /api/v1/aggregate, whose meta
carries ignored_filters alongside total / limit / truncated.
Clients (and AI agents in particular) should treat a present ignored_filters
as "these results are broader than I asked for" and re-query with a field from
the endpoint's schema.
Sorting
Sort with ?sort=field (ascending) or ?sort=-field (descending), restricted
to each endpoint's allow-listed sortable fields. Rejected sort fields are
reported via meta.ignored_sorts (see above).
Includes
Lazy relationships are opt-in via ?include=items,costs. Omitted relationships
are not present in the body (not null).
Sparse fieldsets
Narrow the response body with ?fields=a,b,c, and nested relations with
?fields[items]=id,quantity. id is always included and custom_fields is
addressable as a field. Omitting fields returns the full, byte-identical
default body. Sparse fieldsets are for response projection; saved-view column
selection continues via ?filter_id.
Custom fields
Custom fields are serialised as a flat object: {"custom_fields": {"po_ref": "PO-1"}}.
Dates & money
Dates are ISO 8601 in UTC (2026-01-15T14:30:00Z). Money is returned as decimal
strings ("125.50") — never floats or raw minor units.
Errors
Errors use the Laravel shape:
{ "message": "The given data was invalid.", "errors": { "field": ["..."] } }
Validation failures return 422; auth 401/403; not-found 404; conflicts
409.
Idempotency
Any side-effecting POST (payments, credit notes, issue/void, stock
transactions, transfers, PO send/receive, document generation, …) accepts an
optional Idempotency-Key request header. Replaying the same key returns the
stored response with Idempotency-Replayed: true. Reusing a key with a
different body returns 409; an in-flight key returns 425. Sending the header
is optional by default.
An operator can enable strict mode via api.require_idempotency_key. When
on, it requires the header on every mutating POST /api/v1/... request
(428 if missing) — not just the routes above that also get replay/conflict
semantics. Plain resource creates that carry the header are simply let
through; no replay row is written and no Idempotency-Replayed header is
ever returned for those routes. Strict mode never applies to GET/HEAD/
OPTIONS, to PUT/PATCH/DELETE, or to unauthenticated endpoints
(inbound webhook receivers, the token-in-URL export download) — those callers
have no bearer token and, for inbound webhooks, no way to send a
Signals-specific header at all.
Asynchronous operations
Long-running operations return 202 Accepted with a uniform body:
{ "message": "Accepted", "job_id": "…", "status_url": "/api/v1/jobs/{job}" }
Poll status_url (GET /api/v1/jobs/{job}) for progress. It returns the job's
published status payload, or 404 once the status has expired.
Rate limiting
Requests are rate-limited per token (or per IP when unauthenticated). Responses
carry X-RateLimit-Limit and X-RateLimit-Remaining; a 429 includes
Retry-After. A token may be given a bespoke per-minute limit
(rate_limit_per_minute) from the API Tokens admin screen.
Deprecation
Nothing in v1 is deprecated today. When an endpoint is superseded, its responses
carry Deprecation, Sunset, Link, and Warning headers (see the versioning
policy). Deprecation is a signal, never an immediate removal.
Webhooks & events
Subscribe to events with an outbound webhook; deliveries are HMAC-SHA256 signed
(X-Signals-Signature). Event names are registered in the Webhook Event
Registry (resource.action, e.g. invoice.issued). Adding a new event is an
additive change.
CORS
Cross-origin access is scoped to /api/*. Allowed origins default to the app URL
(override via CORS_ALLOWED_ORIGINS). Rate-limit, request-id, retry,
idempotency-replay, and deprecation headers are exposed to browser clients.
Credentialed cross-origin requests are disabled — use bearer tokens.
Identifier conventions
Primary keys are auto-incrementing integers. Every resource is addressed by
id (/api/v1/rentals/1042), and id is always an integer in request and
response bodies. This is deliberate: it matches the identifier convention of the
industry-standard rental APIs Signals is drop-in compatible with, so an
integration written against those systems addresses Signals records unchanged.
Public UUID secondaries. Where an identifier is printed, scanned, or handed
to a third party — and must therefore stay stable and unguessable independently
of the database row — the record carries a uuid column alongside the integer
PK. Flightcase and Attachment work this way: the integer id remains the
relational key, and route binding resolves the public uuid. A UUID secondary
never replaces id.
Four intentional UUID-PK exceptions. These models use a UUID as the primary key:
| Model | Endpoint area |
|---|---|
ImportBatch |
Imports |
ImportPlan |
Imports |
ImportProfile |
Imports |
ExportJob |
Exports |
They are the async import/export job records. Their identifiers are minted
client-side or queue-side before the row exists — a staged import is
addressable while it is still being assembled, and a caller polling
202 Accepted holds the job identifier before any insert has run — so an
identifier that depends on a database sequence would not work. They are also
short-lived operational artefacts rather than durable business records, so they
carry no external-compatibility obligation to be integers. Everything else in
the API uses integer PKs; these four are the complete list of exceptions.
Cross-surface references (ref)
Every top-level resource in a REST/MCP response carries a ref: a stable,
human-readable identifier formatted entity:id.
{
"rental": {
"id": 1042,
"title": "Summer Festival",
"ref": "rental:1042"
}
}
The entity slug is singular snake_case (rental, account,
catalogue_item_group, warehouse_transfer). The same value and the same format are
stamped on the webhook envelope for that record, so an event delivery and a
resource read correlate without a lookup:
{ "rental": { "id": 1042, ... }, "actor_type": "user", "ref": "rental:1042" }
ref is additive and derived entirely from id — it never replaces the integer
primary key, and every existing field keeps its meaning. It appears on both
show and each row of an index response. Under a sparse projection
(?fields=) it is emitted only when explicitly requested (?fields=name,ref),
so a projection still returns exactly the fields you asked for plus id.
Dry runs (dry_run)
Every lifecycle transition that can be refused before it mutates accepts a
dry_run flag, as ?dry_run=1 or a boolean body field. The endpoint runs the
same preconditions the real call would run, mutates nothing, and returns a
verdict instead of the resource:
{ "dry_run": true, "allowed": false, "reason": "Only a draft can be converted to a quotation.", "code": "invalid_state" }
allowed is the answer; reason is human-readable and code is stable and
safe to branch on. Both are null when allowed is true.
Supported on the rental transitions — convert_to_quote,
convert_to_order, change_status, revert_to_quote, revert_to_enquiry —
and on POST /api/v1/invoices/{invoice}/issue.
change_status additionally returns valid_statuses, and the invoice issue
dry-run adds two previews of what issuing would do:
{
"dry_run": true,
"allowed": true,
"reason": null,
"code": null,
"would_lock_rental": true,
"due_at": "2026-08-27T09:00:00+00:00"
}
Both extras are computed from the very same helpers the issue action uses, so a preview and the subsequent issue cannot disagree.
Dispatch needs no dedicated dry-run endpoint: its precheck is already reachable
without mutating, both through GET /api/v1/rentals/{rental}/available_actions
(which reports the dispatch action's allowed/reason/code) and through the
transition dry-runs that share the same guard pipeline.
A dry run takes no row lock, so it neither checks nor consumes
expected_version — see below.
Optimistic concurrency (expected_version)
Rentals, invoices, purchase orders and virtual stock intakes — the four
event-sourced aggregates — carry an aggregate_version: an integer that
increases by exactly one each time a mutation is successfully committed against
that record. It is returned on every response for those four resources.
To make a mutation conditional on the copy you already read, send back the version you saw:
POST /api/v1/rentals/1042/convert_to_order
{ "expected_version": 7 }
If the record has since moved on, nothing is mutated and the request fails with
409 Conflict:
{
"message": "This rental has changed since you loaded it (expected version 7, current version 9). Reload and retry.",
"code": "version_conflict",
"current_version": 9,
"expected_version": 7,
"errors": {}
}
current_version tells you exactly what to re-read and retry against. This is
deliberately a 409, not the 422 used for domain-rule refusals: the request
is not malformed and may well succeed once you are up to date.
Omitting expected_version runs the mutation unchecked, which is the
default and keeps every existing client working. Opt in per request wherever a
lost update would matter.
The check is performed after the row is locked for update, so it cannot itself be raced, and the version is only incremented once the mutation has committed — a refused transition or a failed guard never advances it.
Accepted on the rental transitions (convert_to_quote,
convert_to_order, change_status, reinstate, reopen,
revert_to_quote, revert_to_enquiry), on the invoice draft mutations
(PATCH /invoices/{invoice}, the draft line-item endpoints) and
POST /invoices/{invoice}/issue, on the purchase order mutations
(PATCH /purchase_orders/{purchase_order}, send, cancel, receive) and on
the virtual stock intake mutations
(PATCH /virtual_stock_intakes/{virtual_stock_intake}, confirm, receive,
return, cancel).
Receiving a purchase order also advances the aggregate_version of any virtual
stock intake it confirms or adjusts as a side effect, so a client holding an
intake's older version is correctly treated as stale.