FieldCamp
Resources

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>Name display mirror beside every relation id (clientIdclientName),
  • customId (and customIdCounter) on objects that use auto display-ids,
  • the stage, which appears twice — as top-level stage and mirrored into data[pipeline.fieldName], usually data.status.

Internal columns — linkedRecords, isDeleted, deletedAt, createdBy, automationSuppressedAt — are deliberately not exposed.

Reading records

GET /api/v1/objects/{slug}:

  • limit — default 50, clamped to 1–100. A non-numeric value falls back to 50 rather than erroring.
  • cursor — the 24-character hex value from meta.next.cursor. Anything else is a hard 400.
  • updated_since — inclusive >= on updatedAt, for delta sync.
  • stage — exact match on the record's stage. status is accepted as an alias; stage wins 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 a 400 naming 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 409 is 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:

StatusMessage
400'client' is a built-in module, not a custom object. Use /api/v1/clients instead.
400Invalid cursor "…" — pass the paging.next_cursor value from the previous page unchanged
400Unknown filter field(s): a, b. See GET /api/v1/objects/<slug>/schema for the field list.
400Unknown field(s) in data: …
404No object with slug '<slug>' on this account.
404Record not found.
409upsert 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 to updated_since, subsequent reads return a plain 404, and object.deleted is 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

GET/api/v1/objects

Authorization

BearerAuth
AuthorizationBearer <token>

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}
GET/api/v1/objects/{slug}/schema

Authorization

BearerAuth
AuthorizationBearer <token>

JWT from POST /api/auth/login (response.data.token). Tokens do not expire.

In: header

Path Parameters

slug*string

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}
GET/api/v1/objects/{slug}

Authorization

BearerAuth
AuthorizationBearer <token>

JWT from POST /api/auth/login (response.data.token). Tokens do not expire.

In: header

Path Parameters

slug*string

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

limit?integer

1–100. Out-of-range or invalid values fall back to 50.

cursor?string

From paging.next.cursor of the previous page.

updated_since?string

ISO-8601. Returns records updated at or after this time.

stage?string

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}
POST/api/v1/objects/{slug}

Authorization

BearerAuth
AuthorizationBearer <token>

JWT from POST /api/auth/login (response.data.token). Tokens do not expire.

In: header

Path Parameters

slug*string

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}
GET/api/v1/objects/{slug}/{id}

Authorization

BearerAuth
AuthorizationBearer <token>

JWT from POST /api/auth/login (response.data.token). Tokens do not expire.

In: header

Path Parameters

slug*string

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.

id*string

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}
PATCH/api/v1/objects/{slug}/{id}

Authorization

BearerAuth
AuthorizationBearer <token>

JWT from POST /api/auth/login (response.data.token). Tokens do not expire.

In: header

Path Parameters

slug*string

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.

id*string

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}
PUT/api/v1/objects/{slug}/by/{field}/{value}

Authorization

BearerAuth
AuthorizationBearer <token>

JWT from POST /api/auth/login (response.data.token). Tokens do not expire.

In: header

Path Parameters

slug*string

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*string

Field NAME to match on, e.g. legacy_row_id. Must exist on the object.

value*string

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}

On this page