Best practices
Guidance for building with the Records API. Endpoint pages describe what each call does; this page recommends how to combine them.
This guidance covers the current (v2) Records API. Prefer it over the older v1 endpoints: v2 adds keyset pagination, a structured filter tree, and safe-to-retry creates via Idempotency-Key. See the Get record API for the record model.
Discover the schema, then reference by ID
record_type_id and field_id are stable; names and slugs can change at any time. Discover the schema once via the List record types API, which returns every record type's id and its fields' ids, and cache those IDs.
Reads and writes also accept record_type_name and field_name, and matching is insensitive to case and separators (Source IP, source_ip, and source-ip all resolve the same field) — convenient for ad-hoc calls, but pin to IDs in automated workflows. Record operations that return a record body include a field_ids map (field slug → immutable ID), so you can resolve IDs from those responses and refresh on a 404.
Search for rows, aggregate for math
- To list or filter records, use the Search records API. Despite its
POSTverb it is a read — the criteria travel in the body. - For counts, sums, averages, or grouped breakdowns, use the Aggregate records API instead of paging through search and summing client-side. It answers in one request, grouped by up to two fields.
Both operate on a single record type per call.
Only request what you need
- Pass a sparse fieldset (
fields[record]=status,severity) on Get or Search to return only the columns you need. JSONandARTIFACTfields are never inlined by default — they come back as a handle,{ "expandable": true, "href": "/api/v2/records/<id>/fields/<field_id>" }. On Get you can passexpand(for exampleexpand=fields.payloadorfields.*) to inline them when you need the value now; otherwise follow thehreflazily via the Get record field API. Writes always return the handle, whatever the value's size.- Child records and linked cases are not embedded unless you ask. On Get, pass
include=child_records,include=child_record_ids, orinclude=cases. Each embed is capped (itsrelationshipsentry carries"truncated": truewhen more exist); page the full set via the List child records, List child record IDs, and List linked cases endpoints.
Paginate with the cursor, not an offset
Search, the record-type list, and the nested list endpoints are keyset-paginated. Set limit (1-500; defaults to 20), filter and sort server-side, and follow page.next_page_token into the after parameter of the next request until no token is returned. Request include_total: true only when you actually need page.total — it costs an extra count.
Build filters as a tree
The filter body parameter is either a single condition { field_id | field_name, operator, value } or an AND group { "op": "AND", "nodes": [...] }; only AND is supported, within a bounded depth and node count. A few things to keep in mind:
- Available operators depend on the field type (for example
TEXTsupportsEQUAL,CONTAINS, andIS_ANY_OF;NUMBERandTIMESTAMPsupportGREATER_THANandLESS_THAN). See the Search records API for the full set. - To narrow by
created_atorupdated_at, prefertime_range(rolling presets or explicit bounds) over hand-built timestamp conditions. - The response echoes the filter and sort actually applied under
applied, so you can diff it against what you sent when a condition is coerced or dropped.
Send timestamps in UTC
TIMESTAMP field values are stored in UTC. When you send a value with a timezone offset (for example 2026-08-13T19:08:15-04:00), v2 converts it to the equivalent UTC instant and stores 2026-08-13 23:08:15 — unlike v1, which dropped the offset and stored the wall-clock time. To avoid any ambiguity, convert to UTC and send a Z-suffixed timestamp (2026-08-13T23:08:15Z).
The record action offers a Convert timezone to UTC option that shifts offset-carrying values to UTC for you.
Make creates safe to retry
POST /records creates a new record on every successful call, so a blind retry after a timeout or dropped connection can duplicate it. Send an Idempotency-Key header with a unique value to make the retry safe:
- Generate the key once per logical create (for example a random UUID) and reuse that same value on every retry of that write — don't mint a new key per attempt, and don't share one key across different records.
- A repeat with the same key and body replays the original response instead of creating a second record.
- Reusing a key with a different body returns a
422(idempotency_key_reuse). - A retry sent while the first request is still in flight returns a
409(idempotency_in_progress); retry once it completes. - If the original record was deleted, a replay returns a
410(idempotency_target_deleted). Don't retry with a new key unless you intend to create another record. - Keys are scoped to your user within the tenant, are at most 255 characters, and expire after 24 hours.
Update and Delete target a single record by ID and are naturally safe to retry.
Handle rate limits
The Records API is rate-limited (see API rate limits). On 429, back off exponentially with jitter and cap the number of attempts.
Use test_mode for test data
- On Create, pass the JSON boolean
test_mode: truein the request body from staging or test workflows to create a test record. - On Update,
test_modeis an optional JSON boolean in the request body. On Delete, it is the query-string valuetrueorfalse. For an authorized write, a mismatch returns422, so a staging job can't accidentally mutate live data. - On Search, pass the JSON boolean
test_mode: trueto search test records instead of live records. Accessing test records requires change control.
Search returns live records by default. Test records remain excluded from Aggregate.
Link records and cases from either side
A record and a case can be linked from the record side with the Link a case API (POST /records/:id/cases) and unlinked with the Unlink a case API (DELETE /records/:id/cases/:case_id); unlink is idempotent. The case-side Add and Remove endpoints manage the same link. For case status, comments, or Cases-only reads, use the Cases API.
Configure retention explicitly
Set ttl_days, retention_column_name, custom_records_limit, and on_limit_reached when you create a record type (see Create record type):
- Enrichment cache:
retention_column_name: "UPDATED_AT",on_limit_reached: "EVICT_OLDEST". - Audit trail:
retention_column_name: "CREATED_AT",on_limit_reached: "REJECT".
Common errors
Every v2 endpoint returns the shared error shape { "error": { "type", "message", "field"?, "hint"? } }.
| Status | Typical cause |
|---|---|
| 400 | Malformed request — invalid body, an unknown parameter, or an invalid or duplicated field reference. |
| 403 | The tenant can't access records or test records, or the token lacks permission for the requested operation. |
| 404 | Record, type, or field not found — or the token can't see it. A not-visible resource returns 404, not 403. |
| 408 | The write timed out. Retry it (with the same Idempotency-Key if you sent one on a create). |
| 410 | An idempotent create replay targeted a record that was subsequently deleted. |
| 422 | Validation failure — a TEXT_ENUM value not in fixed_values, a non-editable record type, a test_mode mismatch. |
| 429 | Rate limited — back off. |
| 503 | A dependency was briefly unavailable (for example idempotency or search). Retry with backoff. |
| 504 | An aggregate request exceeded its 30-second statement timeout. |
Treat a 404 on a resource you expect to exist as a scope check on the API key: a token that can't see a resource is told it doesn't exist, whereas a 403 means the token can see the resource but can't perform the requested operation.