Custom Objects | FieldCamp API
Read and write records on the custom objects your FieldCamp account defines — runtime schema discovery, cursor paging, delta sync, upsert by your own id, and object.* webhooks.
Custom objects are the data types your account defines — a generator, a contract, a piece of equipment, a membership. They are configuration rather than platform code, which is the one thing to keep in mind when integrating: the objects and their fields differ per account and can change while your integration is running, so resolve them at runtime instead of hard-coding a field list.
Two scopes cover everything here: objects:read and objects:write. Send your key as X-Api-Key: fc_live_... or Authorization: Bearer fc_live_... — both work on these routes.
Start with discovery
GET /api/v1/objects lists the objects on the account:
{
"success": true,
"data": [
{ "slug": "unit", "nameSingular": "Unit", "namePlural": "Units",
"isChildObject": false, "parentObjectSlug": null,
"schemaUrl": "/api/v1/objects/unit/schema" }
],
"meta": { "total": 1 }
}Sorted by slug, no paging — meta.total is the length of the array, not a database count. The seven built-in modules (client, job, visit, invoice, estimate, request, task) are excluded; they have their own typed endpoints. Child objects appear in the same flat list, and isChildObject / parentObjectSlug are the only signal that one is nested.
Discovery and CRUD disagree on disabled objects
An object that has been disabled in Settings does not appear in this list, but
its records stay fully readable and writable at /api/v1/objects/{slug}. If you
drive your integration off this list, a disabled object silently drops out of
your sync while its data is still live.
Read the schema before you write
GET /api/v1/objects/{slug}/schema is how you learn what a field is called and what it accepts:
{
"success": true,
"data": {
"object": { "slug": "unit", "nameSingular": "Unit", "namePlural": "Units" },
"fields": [
{ "name": "unit_label", "id": "6a7e...", "label": "Unit Label",
"type": "text", "required": true, "readOnly": false, "system": false,
"options": null, "relation": null },
{ "name": "plan_tier", "id": "6a7e...", "label": "Plan Tier",
"type": "select", "required": false, "readOnly": false, "system": false,
"options": [{ "value": "platinum", "label": "Platinum" }], "relation": null },
{ "name": "clientId", "id": "6a7e...", "label": "Customer",
"type": "relation", "required": false, "readOnly": false, "system": false,
"options": null,
"relation": { "targetObject": "client", "relationshipType": "MANY_TO_ONE", "multi": false } }
],
"pipeline": {
"name": "Unit lifecycle",
"fieldName": "status",
"stages": [{ "value": "active", "label": "Active", "order": 0, "isFinal": false }],
"transitions": { "active": ["retired"] }
}
}
}name is the key you use inside data. id matters only for Client custom properties on /api/v1/clients — which is why this one route in the family deliberately accepts built-in slugs too: GET /api/v1/objects/client/schema is the supported way to discover Client custom-field ids.
What the schema does not tell you
Fields come back in display order, but there is no position key — rely on the
array order. Hidden fields are included and are indistinguishable from visible
ones. Per-locale option labels are dropped. Picker filters, role filters, the
business-versus-individual client restriction, and the Settings "copy fields
when selected" mapping are not exposed at all — so a relation restricted to
business clients will reject your write with
Customer: client must be business (got individual) even though nothing in the
schema warned you. Only one pipeline is returned per object: the default one, or
an arbitrary one if no default is set.
Schema responses are cached for five minutes
Object and field definitions are served from a five-minute per-instance cache, so
a field an admin just added can be missing from /schema — and a write that uses
it can come back 400 Unknown field(s) in data — for a short window. Cache the
schema on your side for a few minutes, and on an unknown-field 400 refetch the
schema and retry once before treating it as a real error. Pipeline stages are read
live, so they can be newer than the field list in the same response.
The record shape
{
"id": "6a84ad6cb761c09157ba9ccc",
"object": "unit",
"stage": "active",
"data": { "unit_label": "Kohler 26RCA", "plan_tier": "platinum",
"clientId": "6a7c8001561d385f71ccff99", "clientName": "Amherst Mill",
"status": "active", "customId": "DEMO-024" },
"createdAt": "2026-08-04T19:47:49.075Z",
"updatedAt": "2026-08-18T21:24:11.004Z"
}data is keyed by field name, case-sensitive. Three things in there are written by FieldCamp rather than by you:
- a
<base>Namedisplay mirror beside every relation id (clientId→clientName), customId(andcustomIdCounter) on objects that use auto display-ids,- the stage, which appears twice — as top-level
stageand mirrored intodata[pipeline.fieldName], usuallydata.status.
Internal columns — linkedRecords, isDeleted, deletedAt, createdBy, automationSuppressedAt — are deliberately not exposed.
Reading records
GET /api/v1/objects/{slug}:
limit— default50, clamped to 1–100. A non-numeric value falls back to 50 rather than erroring.cursor— the 24-character hex value frommeta.next.cursor. Anything else is a hard400.updated_since— inclusive>=onupdatedAt, for delta sync.stage— exact match on the record's stage.statusis accepted as an alias;stagewins when both are sent.- any other key — an exact match on
data.<key>. The field name is case-sensitive and must exist, or the request is a400naming every unknown key.
curl "https://api.fieldcamp.ai/api/v1/objects/unit?plan_tier=platinum&limit=100" \
-H "X-Api-Key: fc_live_..."Paging is an id-ordered forward walk. meta.next is { cursor, limit } while pages remain and null on the last page:
{ "success": true, "data": [ /* … */ ], "meta": { "next": { "cursor": "6a84...", "limit": 50 } } }Two silent behaviours to guard against
An unparseable updated_since is ignored rather than rejected, so a typo'd
timestamp returns your entire collection instead of a delta, with a 200. Validate
the timestamp before you send it and alert on an unexpectedly large first page.
A query key with an empty value — ?plan_tier= — is skipped rather than
validated, so even a misspelt field name passes silently when its value is empty.
Build your query string so an empty variable never becomes a bare parameter.
Two more things worth knowing: limit, cursor, updated_since, stage and status are reserved, so they can never filter a data field of the same name — and a pipelined object does get an auto-provisioned status field, so ?status= always filters the stage, not data.status. Sorting is fixed to id ascending; there is no sort parameter, and a walk is not a point-in-time snapshot, so a record modified mid-walk can be missed or seen twice. Soft-deleted records are excluded with no tombstone.
GET /api/v1/objects/{slug}/{id} returns one record. This route does not verify that the slug exists, so a typo'd slug returns 404 Record not found. rather than a slug error.
Writing records
Create
POST /api/v1/objects/{slug} returns 201 with the record. Body: data (required), status, linkedRecords, suppress_automation.
curl -X POST "https://api.fieldcamp.ai/api/v1/objects/unit" \
-H "X-Api-Key: fc_live_..." \
-H "Content-Type: application/json" \
-d '{ "data": { "unit_label": "Kohler 26RCA", "plan_tier": "platinum",
"clientId": "6a7c8001561d385f71ccff99" },
"status": "active" }'On create, send the stage as `status`
POST reads status. It does not read stage — that name is honoured on
PATCH and on the upsert endpoint, so a body that works for an update silently
lands in the default stage on a create. Sending both is safe and portable. Omit
both and the record starts in the pipeline's first stage by order.
Update
PATCH /api/v1/objects/{slug}/{id} merges over the stored data — keys you omit are preserved, and required-field validation runs against the merged result. There is no way to remove a key; null sets null. Here stage and status are aliases, with stage winning.
Pipeline transitions are advisory on this API
Your onTransition rules do run, but the API does not reject a transition
your pipeline's transitions map does not list, and does not reject a stage value
no stage defines — both return 200. If your integration depends on a legal
progression, enforce it on your side.
Stage mirroring goes both ways: writing data.status also moves the stage. But
object.stage_changed only fires when top-level status was in the body.
There is no If-Match or ETag — last write wins.
Upsert by your own id
PUT /api/v1/objects/{slug}/by/{field}/{value} is the endpoint to use when your system owns the identifier. 200 with meta.created: false on an update, 201 with meta.created: true on a create.
curl -X PUT "https://api.fieldcamp.ai/api/v1/objects/unit/by/legacy_row_id/GP-4417" \
-H "X-Api-Key: fc_live_..." \
-H "Content-Type: application/json" \
-d '{ "data": { "plan_tier": "platinum" } }'Four things to get right:
- Key on a text field. The match is an exact string comparison with no type conversion, so a number or boolean field never matches and every call creates a new record instead of updating.
- Uniqueness is not enforced by the database. The
409is a detector, not a lock, so serialise your calls per key rather than relying on it. - A soft-deleted record holding your key value neither blocks the upsert nor is revived.
- URL-encode the value. On a create it is force-written into
data[field].
Unknown-field rules
Keys inside data are validated against the schema. Three shapes are tolerated so that a read → modify → write round-trip cannot fail on keys FieldCamp wrote itself: customIdCounter, anything beginning with __, and a <base>Name mirror next to a relation <base>Id. customId is not tolerated — strip it before rewriting a record you read.
Top-level body keys are not validated, which has one sharp edge: a misspelt data on a PATCH returns 200 having changed nothing.
Relations
Check relation.multi on the field: an array when true, a single id string when false. An array sent for a single relation silently keeps only the first id. Input is forgiving — a bare id, a number, {$oid}, {id}, {_id}, {recordId}, an array, or a legacy comma-separated string all work. Ids are not checked for existence, except on relations restricted to business or individual clients. Clear a relation with null, or [] on a multi relation.
linkedRecords is accepted on every write but never returned, and it is derived from your relation values anyway — leave it alone. If you do send it, it replaces the stored list, is not schema-validated, and is dropped on a status-only update.
Prefill does not run on API writes
The Settings option "copy fields when selected" is a record-form behaviour. A record created over the API does not get the copied fields, so send every field yourself — including the ones a person would have got for free by picking a customer in the UI.
Validation messages are localized from Accept-Language. Send Accept-Language: en if you log or pattern-match on them. And every side effect after the insert — custom id, inverse-field sync, bidirectional links, afterCreate rules, activity history — is non-blocking, so a 201 does not guarantee they have all completed.
Bulk loading
suppress_automation: true works on POST, PATCH and the upsert PUT. It skips rules (beforeCreate, afterCreate, beforeUpdate, afterUpdate, onTransition), workflow events, and the outbound object.* webhooks. Everything that makes the record correct still runs: required-field validation, relation constraints, coercion and normalisation, denormalized parent names, stage mirroring, auto display-ids, and the activity-history entry.
The suppression is recorded on the record itself, so the background workflow watcher skips it too. The recommended order for a migration is: load with the flag set, spot-check the result, then turn your workflows on.
Linking a job to a record
POST /api/v1/jobs accepts targetObjectSlug and targetRecordId together — both are validated against your account before the job is saved.
curl -X POST "https://api.fieldcamp.ai/api/v1/jobs" \
-H "X-Api-Key: fc_live_..." \
-H "Content-Type: application/json" \
-d '{ "clientId": "6a7c...", "jobType": "one-off",
"targetObjectSlug": "unit", "targetRecordId": "6a84ad6cb761c09157ba9ccc" }'PUT /api/v1/jobs/{id} returns both fields but does not accept them — sending either is a 400. To link a job that already exists, set the job relation field on the record instead, and FieldCamp stamps the reverse pointer onto the matching jobs.
Webhooks
Four events cover every object slug: object.created, object.updated, object.stage_changed and object.deleted. They are generic, so filter on the payload's object field if you only care about one type. Writes made with suppress_automation emit nothing, and object.deleted fires only for in-app deletions.
Subscribe through Webhooks. Keep an updated_since poll as a reconciliation net — see the delivery note on that page for why.
Errors
This family returns two shapes. Endpoint errors carry no code:
{ "success": false, "error": "Unknown filter field(s): plan_teir.", "message": "Unknown filter field(s): plan_teir." }Auth, scope and rate-limit errors do:
{ "success": false, "code": "FORBIDDEN", "message": "Missing required scope: objects:write" }Read message first and fall back to error. Messages you may want to match on:
| Status | Message |
|---|---|
400 | 'client' is a built-in module, not a custom object. Use /api/v1/clients instead. |
400 | Invalid cursor "…" — pass the paging.next_cursor value from the previous page unchanged |
400 | Unknown filter field(s): a, b. See GET /api/v1/objects/<slug>/schema for the field list. |
400 | Unknown field(s) in data: … |
404 | No object with slug '<slug>' on this account. |
404 | Record not found. |
409 | upsert key matched more than one record |
The 400 cursor message and our OpenAPI description call it paging.next_cursor; the JSON key is actually meta.next.cursor.
Limits and what is deliberately absent
Sixty requests per minute per key. Beyond that:
- No
DELETE. Archive a record by moving it to a terminal stage. An in-app delete is a soft delete: it is invisible toupdated_since, subsequent reads return a plain404, andobject.deletedis your only signal. - No batch writes, no CSV endpoint, no full-text search, and no complex filters — every filter is an exact match.
Several product features have no API equivalent today: notes, files, activity and history, QR codes, correspondence, the financial summary, kanban and grouping views, stage aggregates, the relation filter builder, and CSV import/export. If you need one of those over the API, tell us.
Endpoints
/api/v1/objectsAuthorization
BearerAuth JWT from POST /api/auth/login (response.data.token). Tokens do not expire.
In: header
Response Body
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/v1/objects"{ "success": true, "code": "success.created", "message": "Client created successfully", "data": null}/api/v1/objects/{slug}/schemaAuthorization
BearerAuth JWT from POST /api/auth/login (response.data.token). Tokens do not expire.
In: header
Path Parameters
Custom object slug, e.g. unit. Built-in modules (client, job, invoice, visit, estimate, request, task) are rejected with 400 — use their dedicated /api/v1 routes.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/v1/objects/string/schema"{ "success": true, "code": "success.created", "message": "Client created successfully", "data": null}/api/v1/objects/{slug}Authorization
BearerAuth JWT from POST /api/auth/login (response.data.token). Tokens do not expire.
In: header
Path Parameters
Custom object slug, e.g. unit. Built-in modules (client, job, invoice, visit, estimate, request, task) are rejected with 400 — use their dedicated /api/v1 routes.
Query Parameters
1–100. Out-of-range or invalid values fall back to 50.
From paging.next.cursor of the previous page.
ISO-8601. Returns records updated at or after this time.
Exact pipeline stage value. status is accepted as an alias.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/v1/objects/string"{ "success": true, "code": "success.created", "message": "Client created successfully", "data": null}/api/v1/objects/{slug}Authorization
BearerAuth JWT from POST /api/auth/login (response.data.token). Tokens do not expire.
In: header
Path Parameters
Custom object slug, e.g. unit. Built-in modules (client, job, invoice, visit, estimate, request, task) are rejected with 400 — use their dedicated /api/v1 routes.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/api/v1/objects/string" \ -H "Content-Type: application/json" \ -d '{}'{ "success": true, "code": "success.created", "message": "Client created successfully", "data": null}/api/v1/objects/{slug}/{id}Authorization
BearerAuth JWT from POST /api/auth/login (response.data.token). Tokens do not expire.
In: header
Path Parameters
Custom object slug, e.g. unit. Built-in modules (client, job, invoice, visit, estimate, request, task) are rejected with 400 — use their dedicated /api/v1 routes.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/v1/objects/string/665f1c2ab3e4d5f6a7b8c9d0"{ "success": true, "code": "success.created", "message": "Client created successfully", "data": null}/api/v1/objects/{slug}/{id}Authorization
BearerAuth JWT from POST /api/auth/login (response.data.token). Tokens do not expire.
In: header
Path Parameters
Custom object slug, e.g. unit. Built-in modules (client, job, invoice, visit, estimate, request, task) are rejected with 400 — use their dedicated /api/v1 routes.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X PATCH "https://example.com/api/v1/objects/string/665f1c2ab3e4d5f6a7b8c9d0" \ -H "Content-Type: application/json" \ -d '{}'{ "success": true, "code": "success.created", "message": "Client created successfully", "data": null}/api/v1/objects/{slug}/by/{field}/{value}Authorization
BearerAuth JWT from POST /api/auth/login (response.data.token). Tokens do not expire.
In: header
Path Parameters
Custom object slug, e.g. unit. Built-in modules (client, job, invoice, visit, estimate, request, task) are rejected with 400 — use their dedicated /api/v1 routes.
Field NAME to match on, e.g. legacy_row_id. Must exist on the object.
The value to match. URL-encode it.
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X PUT "https://example.com/api/v1/objects/string/by/string/string" \ -H "Content-Type: application/json" \ -d '{}'{ "success": true, "code": "success.created", "message": "Client created successfully", "data": null}Related articles
- Custom objects in FieldCamp
- Clients resource — for Client custom properties
- Jobs resource — for linking a job to a record
- Webhooks
- Rate limits