Documentation

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
  • PATCH is a partial update — send only the fields you're changing. There is no PUT.
  • DELETE is 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 return 404 afterwards.
  • Some resources expose actions — a POST that 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/generate runs 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 returns 404; an action the record's current state doesn't allow — already processed, nothing to refund — returns 409 state_conflict.
  • Some resources are read-only or not listable; writes or lists against them return 402 feature_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=doe finds "Doerr"); email, state, and zip match exactly; phone ignores formatting — (801) 555-0100 and 8015550100 match the same record across all phone fields.
  • An unsupported filter is an error, not a no-op: you get 422 naming the parameter and listing the supported set, so a typo can never silently return the whole collection.
  • Filters are preserved in the pagination Link header — following rel="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 returns 422 listing what's sortable.
  • With no ?sort=, results come back in the resource's default order.
  • Sort is preserved in the Link header 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 returns 403. Each embedded record is permission-filtered exactly like fetching it directly.
  • On a single record (/customers/42), you can include any declared, viewable relationship. Each relationships.<name> is capped at 100 records — if more exist, you get the first 100 and the response adds meta.includes_truncated (an array of the capped relationship names) and meta.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 own relationships.<name>, but only a subset of relationships is supported there (for customers: notes, tickets, work_orders). Requesting one outside that subset returns 422 listing 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_orders is empty for customers whose work orders you can't see, while notes/tickets still 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 Link response header with rel="next", rel="prev", rel="first", and rel="last" URLs. Follow these URLs verbatim — treat them as opaque and don't construct page URLs yourself. When you set an explicit per_page, it's preserved across all page links so the window stays consistent.
  • meta.pagination in the body with page, per_page, total, and last_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 a count_only request — a malformed sort or include name returns 422 — 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-After rather 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

Ask about the docs
Ask about the docs
Answers from the SecurityTrax documentation

Ask about a feature, setting, or workflow.

Answers come from the documentation. Double-check anything important. AI features are subject to the AI Terms.