# Objects and fields (/docs/custom-fields)



Merchants can extend Karma's objects with their own fields: a producer and vintage on a wine, a purchase-order reference on a tab, a delivery zone on a table. Fields are typed, inherit down the location tree, carry a version for safe concurrent edits, and appear on the object's normal API responses.

Use fields when the data has a type and a meaning. For opaque integration identifiers such as your own SKU or shop ID, the free-form `metadata` object on items and locations is still the right place.

For group attributes, linked-item values on variants, and customer checkout input, see [Variant custom fields](/docs/variant-custom-fields).

## Objects [#objects]

Fields attach to one of ten object types. The `objectType` path segment and the record identifier are:

| `objectType`      | Record id                             |
| ----------------- | ------------------------------------- |
| `items`           | inventory item id                     |
| `guests`          | user id, scoped to the location       |
| `reservations`    | booking id                            |
| `tables`          | table UUID                            |
| `locations`       | the location id itself                |
| `tabs`            | tab UUID                              |
| `orders`          | kitchen order UUID                    |
| `purchases`       | purchase id                           |
| `retailer_users`  | staff user id, scoped to the location |
| `cost_centers`    | cost center id                        |
| `variant_groups`  | variant group id                      |
| `loyalty_members` | loyalty membership account id         |

Every route lives under `/api/v1/locations/{locationId}/object-types/{objectType}` and uses the standard envelope. Reading and writing values takes the object's own read and update permissions; managing definitions takes the settings permission.

## Definitions [#definitions]

A definition has an immutable `id`, an immutable `key`, a type, a label, a group, validation config and channel policies. Keys are namespaced by owner: merchant fields are `loc<ownerLocationId>.<key>`, Karma-provided fields are `karma.*`.

| Type                 | Value on the wire                         |
| -------------------- | ----------------------------------------- |
| `text`, `long_text`  | string                                    |
| `number`             | decimal as a **string**, e.g. `"12.5"`    |
| `currency`           | `{ "amount": "80", "currency": "SEK" }`   |
| `boolean`            | `true` / `false`                          |
| `date`               | `"2026-09-24"`                            |
| `select`, `taxonomy` | an option **id**, never its label         |
| `multiselect`        | array of option ids                       |
| `formula`, `rollup`  | computed, read-only                       |
| `relation`           | id of a record of `config.relationObject` |

Definitions inherit: a field created at a parent location applies to every descendant. A descendant may customise the label, group and placement and add its own options through the `extensions` route, but cannot change the type, validation or access policy.

Fetch the effective schema for a location and object:

```bash
curl "$COMMON_API/api/v1/locations/1234/object-types/items/fields" \
  -H "X-API-Key: $KARMA_API_KEY"
```

The response lists `fields` with their `config`, `channels`, `revision`, `inherited` and `canWrite`, plus `capabilities` telling you whether this location may customise fields (`custom_fields`, part of Intelligence) and use Kaia suggestions.

Create a field:

```bash
curl -X POST "$COMMON_API/api/v1/locations/1234/object-types/tabs/fields" \
  -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \
  -d '{
    "key": "po_reference",
    "label": "PO reference",
    "group": "Accounting",
    "type": "text",
    "config": { "maxLength": 40 },
    "channels": {
      "staff": true, "table": true, "tableDefault": false,
      "guestDisplay": false, "guestInput": false,
      "apiRead": true, "apiWrite": true,
      "analytics": true, "aiSource": false
    }
  }'
```

Returns 201 with a `Location` header. Updating goes through `PATCH .../fields/{id}` with the `revision` you last read; a stale revision is rejected with 409. Fields are archived, never deleted, and a key stays reserved after archival.

<Callout title="Channels are the access policy">
  An API key only sees fields with `apiRead` and can only write fields with `apiWrite`. A field that is staff-only is simply absent from API responses. Guest surfaces see only `guestDisplay` fields, and never schema or configuration.
</Callout>

## Values [#values]

Read a record's values:

```bash
curl "$COMMON_API/api/v1/locations/1234/object-types/tabs/records/59e4ce7d-b801-4323-af9d-ba0ca4bd757a/values" \
  -H "X-API-Key: $KARMA_API_KEY"
```

```json
{
  "status": "success",
  "data": {
    "values": [
      {
        "fieldId": "2c6f…",
        "value": "PO-42",
        "version": 3,
        "source": "api",
        "status": "fresh",
        "sourceLocationId": 1234,
        "updatedAt": "2026-09-24T09:12:40.000Z"
      }
    ]
  }
}
```

`source` is who last set it: `manual`, `api`, `ai`, `formula` or `rollup`. `status` matters for computed fields: `fresh`, `stale` (an input changed and the recompute is pending), `error` (see `error`) or `frozen` (the plan no longer covers calculations, the last value is kept). Unset fields are omitted; an explicitly cleared field is present with `value: null`.

Write values atomically, one call per record, up to 100 changes. Use the `expectedVersion` you last read to reject stale edits; a mismatch fails the whole request with 409 `VERSION_CONFLICT` and nothing is written. It is optional: omitting it intentionally updates the latest value. Identify each change by `fieldId` or a stable `key`.

```bash
curl -X PATCH "$COMMON_API/api/v1/locations/1234/object-types/tabs/records/59e4ce7d-…/values" \
  -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \
  -d '{
    "changes": [
      { "fieldId": "2c6f…", "expectedVersion": 3, "value": "PO-43" },
      { "fieldId": "9a10…", "expectedVersion": 0, "value": { "amount": "1250", "currency": "SEK" } }
    ]
  }'
```

A never-set field has version `0`. Send `"value": null` to clear a field, or `"reset": true` on a shared item at a child location to drop the local override and inherit the parent's value again. Validation failures return 422 `FIELD_VALIDATION` with the offending field in the message.

Read many records at once with `POST .../values/batch` and up to 200 `ids`.

## Values on normal create and update calls [#values-on-normal-create-and-update-calls]

Authenticated staff and API integrations can include optional `customFields` operations in the normal record request. They are saved in the same transaction as the record, so invalid values or a stale version roll back the whole edit.

For example, update a booking's guest count and meal abbreviation in one call:

```http
PUT /api/v1/bookings/{bookingId}
Content-Type: application/json
```

```json
{
  "guestCount": 4,
  "customFields": [
    { "fieldId": "<MA-field-UUID>", "expectedVersion": 1, "value": "LU" }
  ]
}
```

The same array is accepted on booking creation, with `expectedVersion: 0` for each initial value. Definitions must already exist; inline operations set values, they do not create field definitions.

| Object               | Normal calls accepting inline operations                                                        |
| -------------------- | ----------------------------------------------------------------------------------------------- |
| Reservations         | `POST /bookings`, `PUT /bookings/{id}`                                                          |
| Items                | `POST /inventory`, `PUT /inventory/{id}`; bulk-upsert item rows and the bulk update `patch`     |
| Guests               | `POST /locations/{locationId}/customers`, `PATCH /locations/{locationId}/customers/{userId}`    |
| Tables               | `POST /tables`, `PUT /tables/{id}`, `PUT /tables/bulk`                                          |
| Locations            | `POST /locations`, `PATCH /locations/{locationId}/settings`                                     |
| Tabs                 | `POST /tabs/resolve`, `PATCH /tabs/{id}`, `PATCH /tabs/{id}/metadata`                           |
| Orders               | `POST /orders/create`, `POST /orders/append-to-dispatch`, `PATCH /orders/{id}`                  |
| Purchases            | `PATCH /purchases/{id}`, `POST /purchases/{purchaseIntentId}/connect-customer`                  |
| Staff users          | `PUT /retailer/users/{id}`, `PUT /retailer/users/me`                                            |
| Cost centers         | `POST /cost-centers`, `PUT /cost-centers/{id}`                                                  |
| Variant groups       | `POST /variant-groups`, `PUT /variant-groups/{id}`; top-level `customFields` edits group values |
| Variant linked items | `PATCH /variants/{id}?locationId={locationId}`, or `variants[].customFields` in group writes    |

All paths above start with `/api/v1`. The general PATCH calls on tabs, orders and purchases edit custom fields only; their lifecycle and payment actions keep their dedicated APIs. Staff-user creation creates an invitation, so values attach to the actual user after acceptance. Purchases are created by payment flows, rather than a general record-create endpoint.

* Omit `customFields` to preserve values on updates. An empty array makes no field changes.
* You do not need to include unset optional fields. On creation, writable required fields must have a value; conditional required fields apply when their condition matches. A submitted update checks the resulting required values, preserving existing values for omitted fields.
* Send `value: null` to clear an optional field. Required fields cannot be cleared. Use `reset: true` without `value` to drop an inherited-item override; a required field must still have an inherited value.
* Send the last-read `expectedVersion` for updates. A conflict returns 409 `VERSION_CONFLICT`; invalid values return 422 `FIELD_VALIDATION`. Permissions, API channel policies and plan requirements are identical to the values endpoint.
* Guest checkout continues to use the guest form's schema token and answers, rather than staff field operations.

The response `customFields` array contains labels and display values as well as raw values and versions. Do not copy that entire read projection into a write: send only `fieldId`, `expectedVersion` and `value` (or `reset`).

## Values on normal responses [#values-on-normal-responses]

You do not need the fields API to read values. Native detail and list responses for supported object types carries a read-only projection of the fields your key may read:

```json
{
  "id": 456,
  "title": "Chianti Classico",
  "customFields": [
    { "fieldId": "…", "key": "loc1234.producer", "label": "Producer", "group": "Wine",
      "type": "text", "value": "Antinori", "displayValue": "Antinori", "version": 2, "status": "fresh" }
  ],
  "metadata": {
    "externalId": "sku-3732185",
    "customFields": { "loc1234.producer": "Antinori" }
  }
}
```

`metadata.customFields` is the same data keyed by field key, convenient for lookups. Both are current values, not a snapshot from the time of a sale. Writing `metadata.customFields` through a generic metadata update is rejected; values change through inline write operations or the values route above. Webhook payloads carry the same projection, described in [Receiving webhooks](/docs/webhooks#custom-fields-and-metadata).

## Searching by field [#searching-by-field]

`POST .../search` filters and sorts records by typed values with cursor pagination:

```bash
curl -X POST "$COMMON_API/api/v1/locations/1234/object-types/items/search" \
  -H "X-API-Key: $KARMA_API_KEY" -H 'Content-Type: application/json' \
  -d '{
    "filters": [
      { "field": "7b3e…", "operator": "eq", "value": "opt_red" },
      { "field": "c412…", "operator": "gte", "value": "2018" }
    ],
    "sort": { "field": "c412…", "descending": true },
    "limit": 50
  }'
```

Operators are `eq`, `gte`, `lte`, `contains` (multiselect membership or text) and `unset`. Up to five filters; `query` searches titles; `ids` restricts to a set. Follow `nextCursor` from the response for the next page. Filtering by generic `metadata` keys still goes through the object's own list endpoint; this route is for typed field values.

## Errors [#errors]

| Status | Code                                              | Meaning                                                         |
| ------ | ------------------------------------------------- | --------------------------------------------------------------- |
| 400    | `INVALID_INPUT`, `INVALID_FILTER`                 | Malformed body, or a filter names a field you cannot read       |
| 403    | `FORBIDDEN`, `PLAN_REQUIRED`                      | Missing permission, channel, or the `custom_fields` entitlement |
| 404    | `NOT_FOUND`                                       | Record or field does not exist at this location                 |
| 409    | `VERSION_CONFLICT`, `CONFLICT`                    | Stale version or revision, or the key already exists            |
| 422    | `FIELD_VALIDATION`, `FIELD_LIMIT`, `SCHEMA_LIMIT` | Value rejected, or a bound reached                              |

Bounds: 100 merchant fields per location, 500 effective fields per object type, 1,000 options per field, 200 records per batch or search page.
