Requests and Responses
Every endpoint in the API shares the same conventions. Learn them once here and the per-resource reference becomes predictable.
URLs and verbs
Resources use plural, lowercase names with underscores (customers,
customer_notes, invoices) and are addressed directly by id:
GET /{profile}/user/v4/{resource} list a collection
POST /{profile}/user/v4/{resource} create a record
GET /{profile}/user/v4/{resource}/{id} fetch one record
PATCH /{profile}/user/v4/{resource}/{id} update fields on a record
DELETE /{profile}/user/v4/{resource}/{id} delete a record
POST /{profile}/user/v4/{resource}/{id}/{action} run an action on a record
POST /{profile}/user/v4/{resource}/{action} run an action on the collection
PATCHis a partial update — send only the fields you're changing. There is noPUT.DELETEis permanent from your side — there is no API or in-app way to undo it. Records are soft-deleted (not physically erased), so SecurityTrax support can restore one on request, but there's no self-service recovery. Deleted records disappear from the API: reads return404afterwards.- Some resources expose actions — a
POSTthat does something a field update can't. Most run on a record (/{resource}/{id}/{action}), like charging a payment (customer_payments/{id}/process). A few run on the collection with no id (/{resource}/{action}) to create a record the engine — not you — assembles:POST /customer_payables/generateruns the payroll engine to produce a payable and returns it (201), because a payable can't be built from raw fields. Each resource's page lists the actions it supports, their request body, and their outcomes. An action a resource doesn't support returns404; an action the record's current state doesn't allow — already processed, nothing to refund — returns409state_conflict. - Some resources are read-only or not listable; writes or lists against them
return
402feature_not_enabled.
Request bodies
POST and PATCH accept JSON. Fields go under data.attributes; links to
parent records (like the customer a note belongs to) go under
data.relationships:
curl -X POST "https://portal.securitytrax.com/acme/user/v4/customer_notes" \
-H "Authorization: Bearer stx_acme_..." \
-H "Content-Type: application/json" \
-d '{
"data": {
"attributes": { "subject": "Panel swap requested", "note": "Customer called to schedule." },
"relationships": { "customer": { "data": { "type": "customers", "id": 1 } } }
}
}'
A referenced relationship must exist — a bad id returns 422 naming the
relationship, so a typo can never create an orphaned record.
The response envelope
Every response, success or failure, has the same six fields:
{
"ok": true,
"data": { "type": "customer_notes", "id": "56", "attributes": { "...": "..." } },
"summary": null,
"breadcrumbs": [],
"meta": { "surface": "api" },
"errors": []
}
| Field | What it is |
|---|---|
ok |
true or false — the one field to branch on. |
data |
The resource (object), the collection page (array), or null on failure. |
summary |
An optional short human-readable description of the result. |
breadcrumbs |
Suggested follow-up calls related to this record. Safe to ignore. |
meta |
Request metadata — pagination details on list responses. |
errors |
Empty on success; one or more error objects on failure. |
Permissions shape the data. A response contains only the fields the
token's user may view, so two users can receive different fields for the same
record. A record the user can't see at all returns 404 — the API never
confirms the existence of data you can't access.
Data formats
| Type | Format | Example |
|---|---|---|
| Datetime | ISO-8601 with UTC offset | 2026-07-10T14:03:22-06:00 |
| Date | ISO-8601 date | 2026-07-10 |
| Money / decimals | String-encoded decimal (no float rounding) | "49.99" |
| Record ids | String in data.id, number when referenced elsewhere |
"56" |
Errors
Failures return ok: false and one or more error objects:
{
"ok": false,
"data": null,
"errors": [
{ "code": "validation_failed", "status": 422, "field": "note_type_id",
"reason": "validation", "detail": "the selected note type does not exist" }
]
}
Branch on code — it's a stable machine string. detail is human-readable
prose and may change; never parse it. field appears when the error is about
one specific field.
code |
HTTP | Meaning | Retry? |
|---|---|---|---|
unauthenticated |
401 | Missing or invalid token | After re-authenticating |
permission_denied |
403 | Authenticated, but not allowed to do this | No |
tenant_suspended |
403 | The company's account is suspended | No |
feature_not_enabled |
402 | The resource or operation isn't available on this account | No |
not_found |
404 | The record doesn't exist (or isn't visible to you) | No |
validation_failed |
422 | The request body failed validation | After fixing the request |
conflict |
409 | The request clashed with concurrent activity | After refetching |
state_conflict |
409 | The record's current state doesn't allow this action | No |
dependency_in_use |
409 | Delete blocked — other records still reference this one | No |
record_locked |
423 | The record is locked | After it's unlocked |
rate_limited |
429 | Too many requests | After the Retry-After delay |
upstream_unavailable |
502 | A third-party service we depend on failed | After a delay |
internal |
500 | Unexpected server fault | After a delay |
The set is closed — new codes are only added as a documented contract change,
so an exhaustive switch on code is safe to write.
Filtering collections
List endpoints accept flat query-param filters. Each resource documents its
supported filters; for example, customers supports fname, lname,
business_name, email, phone, city, state, and zip:
curl "https://portal.securitytrax.com/acme/user/v4/customers?lname=Doe&state=UT" \
-H "Authorization: Bearer stx_acme_..."
- Name fields match anywhere in the value (
lname=doefinds "Doerr"); email, state, and zip match exactly; phone ignores formatting —(801) 555-0100and8015550100match the same record across all phone fields. - An unsupported filter is an error, not a no-op: you get
422naming the parameter and listing the supported set, so a typo can never silently return the whole collection. - Filters are preserved in the pagination
Linkheader — followingrel="next"keeps the filter applied.
Some filters are enumerated — they accept a fixed set of values rather than
free text, and an unrecognized value is a 422 naming the accepted set (not a
silent empty result). The customer_notes list has one: record_type, which
accepts note, ticket, or work_order. That resource holds all three record
types in one place, so the unfiltered list returns them mixed; record_type
narrows to one:
# only work orders
curl "https://portal.securitytrax.com/acme/user/v4/customer_notes?record_type=work_order" \
-H "Authorization: Bearer stx_acme_..."
You still only see the records your permissions allow — record_type narrows
the list, it doesn't grant access (work orders remain gated by the customer
work-orders permission). customer_notes has a second enumerated filter,
status (open / closed — whether a ticket or work order has been closed).
Date ranges use a <field>_from / <field>_to pair — both optional and
inclusive. Pass a date (2026-07-01) or a datetime (2026-07-01 09:30:00); a
bare date spans the whole day, and an unparseable value returns 422. Every
resource exposes created_at_from/_to and updated_at_from/_to (the DB
audit stamps) — use updated_at_from to poll for everything changed since
your last sync. Resources add domain-specific ranges too: customers has
sale_date, created, and lead_created — each a different date, documented
on the customers reference page — and customer_notes has follow_up_date.
# customers changed since a timestamp (incremental sync)
curl "https://portal.securitytrax.com/acme/user/v4/customers?updated_at_from=2026-07-01" \
-H "Authorization: Bearer stx_acme_..."
Sorting collections
Order a list with ?sort=. Prefix a field with - for descending, and
comma-separate fields to break ties:
# customers by last name (A→Z), then most recent sale first
curl "https://portal.securitytrax.com/acme/user/v4/customers?sort=lname,-sale_date" \
-H "Authorization: Bearer stx_acme_..."
- Each resource documents its sortable fields (for
customers:id,fname,lname,business_name,city,state,sale_date,created). An unsupported field returns422listing what's sortable. - With no
?sort=, results come back in the resource's default order. - Sort is preserved in the
Linkheader alongside filters, so paging keeps the order.
Expanding related records (include)
?include= embeds related collections in the same response instead of requiring
follow-up calls:
curl "https://portal.securitytrax.com/acme/user/v4/customers/42?include=notes,billing" \
-H "Authorization: Bearer stx_acme_..."
{
"ok": true,
"data": {
"type": "customers", "id": "42",
"attributes": { "...": "..." },
"relationships": {
"notes": [ { "type": "customer_notes", "id": "7", "attributes": { "...": "..." } } ],
"billing": []
}
},
"meta": { "surface": "api" }
}
- Only a resource's declared relationships can be included — an unknown
name returns
422, and a relationship you lack permission to view returns403. Each embedded record is permission-filtered exactly like fetching it directly. - On a single record (
/customers/42), you can include any declared, viewable relationship. Eachrelationships.<name>is capped at 100 records — if more exist, you get the first 100 and the response addsmeta.includes_truncated(an array of the capped relationship names) andmeta.include_item_limit. To read the full set, page that relationship's own collection endpoint instead (e.g./customer_notes?customer_id=42). - On a collection (
/customers), each row gets its ownrelationships.<name>, but only a subset of relationships is supported there (forcustomers:notes,tickets,work_orders). Requesting one outside that subset returns422listing what is supported. - On a collection there is no relationship-level
403— rows on one page can belong to different locations, so each expanded record is permission-filtered individually. A relationship you can't view for a given row comes back as an empty array for that row (e.g.include=work_ordersis empty for customers whose work orders you can't see, whilenotes/ticketsstill populate).
Pagination
List endpoints use client-controlled offset pagination. Request pages with
?page=N (1-based) and set the window size with ?per_page=M. per_page
defaults to 25 and is capped at 100 — a larger value is clamped down, and the
response echoes the effective size. Each list response includes:
- A standard
Linkresponse header withrel="next",rel="prev",rel="first", andrel="last"URLs. Follow these URLs verbatim — treat them as opaque and don't construct page URLs yourself. When you set an explicitper_page, it's preserved across all page links so the window stays consistent. meta.paginationin the body withpage,per_page,total, andlast_page.
Link: <https://portal.securitytrax.com/acme/user/v4/customers?page=2&per_page=50>; rel="next",
<https://portal.securitytrax.com/acme/user/v4/customers?page=9&per_page=50>; rel="last"
To walk a full collection: request page 1, then follow rel="next" until it's
absent.
Count only
Add ?count_only=true to any list request to get just the total — without
fetching or building the records:
curl "https://portal.securitytrax.com/acme/user/v4/customers?count_only=true&state=UT" \
-H "Authorization: Bearer stx_acme_..."
The response has an empty data array and the count in meta:
{ "ok": true, "data": [], "meta": { "count_only": true, "pagination": { "total": 188 } } }
meta.count_only: true marks the empty data as intentional (not "no results").
It composes with every filter — ?count_only=true&updated_at_from=2026-07-01
answers "how many changed since July 1" — and skips the row fetch and per-record
projection entirely, so it's cheaper than paging. The total is the same value the
paginated list reports in meta.pagination.total.
Note.
?sort=and?include=are still validated on acount_onlyrequest — a malformed sort or include name returns422— even though neither affects a count.
Rate limits
Requests are rate limited at three levels. All three apply at once; the first one you exceed rejects the request:
| Limit | Scope | Default |
|---|---|---|
| Per IP address | All requests from one address, authenticated or not | 120 per minute |
| Per token | All requests using one API token | 60 per minute |
| Per company | All requests to your company, across all tokens | 600 per minute |
When you exceed a limit the API returns 429 rate_limited with a
Retry-After header saying how many seconds to wait:
{
"ok": false,
"data": null,
"meta": { "surface": "api" },
"errors": [{ "code": "rate_limited", "status": 429 }]
}
Well-behaved clients back off for the Retry-After duration and retry.
Note. Defaults may change, and limits can be adjusted per deployment — always honor
Retry-Afterrather than hardcoding the numbers. If your integration needs sustained throughput above these limits, contact SecurityTrax support.
Two practical consequences of the three levels:
- Spreading work across multiple tokens raises your per-token headroom but not the company-wide ceiling.
- The per-company limit is shared with every other integration your company runs — a burst from one can rate-limit another. Smooth your request rate rather than sending bursts.
Identify your client
We recommend sending two headers identifying your integration:
X-SecurityTrax-Client: my-integration
X-SecurityTrax-Client-Version: 1.2.0
They're optional, but they let SecurityTrax attribute API activity to your integration and reach out about compatibility instead of breaking you.
Related
- Authentication — tokens and the
Authorizationheader. - Introduction — the base URL and your first request.