SIGNALS Documentation
API Reference

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.