# Metadata

Metadata is a map of string keys to string values you attach to a record for
your own purposes: an id in your CRM or ERP, a segment, an owner, a flag your
integration reads. LedgerBee stores the map with the record, returns it on
every read, and lets you filter list endpoints on it. LedgerBee does not
interpret the values.

## The map

```json
{ "crmSegment": "enterprise", "accountOwner": "jane" }
```

| Limit | Value |
| --- | --- |
| Entries per record | 50 |
| Key length | 128 characters |
| Value length | 500 characters |
| Filter pairs per list request | 10 |

Keys are trimmed before they are stored, and a key must not be empty after
trimming. Keys are compared case-insensitively: `Tier` and `tier` are the same
key, and a request that carries both is rejected with `METADATA_DUPLICATE_KEY`.
Values are stored as sent, including an empty string.

## Where metadata is available

| Resource | Write | Read | Filter |
| --- | --- | --- | --- |
| Customers | `POST /v1/customers`, `PATCH /v1/customers/{id}` | `GET /v1/customers`, `GET /v1/customers/{id}` | `GET /v1/customers` |
| Customer groups | In the LedgerBee app | `GET /v1/customers/groups` | `GET /v1/customers/groups` |
| Suppliers | `POST /v1/suppliers` | `GET /v1/suppliers` | `GET /v1/suppliers` |
| Vendors (deprecated alias of suppliers) | `POST /v1/vendors` | `GET /v1/vendors` | `GET /v1/vendors` |
| Products | `POST /v1/products`, `PATCH /v1/products/{id}` | `GET /v1/products`, `GET /v1/products/{id}` | `GET /v1/products` |

Reads and filters use the resource's read scope (`customers-read`,
`suppliers-read`, `products-read`); writes use its write scope
(`customers-write`, `suppliers-write`, `products-write`). See the
[API Reference](/api) for each operation.

Suppliers and vendors are the same records under two route families, so
metadata written through one is returned by the other, under the same
`metadata` field name. Supplier metadata is also editable in the LedgerBee app.

A customer group's metadata is set in the LedgerBee app and is read-only over
the API. A key holding only the `checkout` scope receives each group slimmed to
`id`, `number`, and `name`; the metadata requires `customers-read`.

## Writing metadata

Send `metadata` on a create request to store the map with the new record.

```bash
curl -X POST 'https://api.ledgerbee.com/api/v1/products' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "productNumber": "P001",
    "name": "Web hosting",
    "unit": "pcs",
    "productGroupId": "0197a943-2325-7829-b835-b6c71a293020",
    "metadata": { "sku": "WH-100", "catalogSection": "hosting" }
  }'
```

The map is validated before the record is created, so an invalid map is
rejected and no record exists. The key `__proto__` is reserved and cannot be
written. The create responses for customers, suppliers
and vendors carry only the new record's `id` and number; read the metadata back
through the single-record or list endpoint. The product create response carries
the map.

On an update, `metadata` replaces the stored map wholesale. Send the complete
map you want to end up with; a key you leave out is removed. An empty object
clears every entry. Omit the field to leave the metadata unchanged.

```bash
curl -X PATCH 'https://api.ledgerbee.com/api/v1/customers/0197a943-2325-7829-b835-b6c71a293040' \
  -H 'x-api-key: <your-key>' \
  -H 'Content-Type: application/json' \
  -d '{ "metadata": { "crmSegment": "enterprise" } }'
```

To change one key, read the current map, change the key, and send the whole
map back.

## Reading metadata

Every response that returns the full record carries its `metadata` map: the
single-record read, the product create and update responses, and the customer
update response. List endpoints return it on each item, so a sync can read the
map without a per-record request. A record with no metadata returns `{}`. The
customer, supplier and vendor create responses return only the id and number,
so read the record afterwards for its map.

## Filtering a list

Pass one or more `metadata[key]=value` query parameters to a list endpoint. A
record matches only when its metadata carries every given key with exactly the
given value: matching is exact on both key and value, and several pairs combine
with AND. At most 10 pairs per request; more returns 400
`METADATA_TOO_MANY_FILTERS`.

```bash
curl -G 'https://api.ledgerbee.com/api/v1/suppliers' \
  -H 'x-api-key: <your-key>' \
  --data-urlencode 'metadata[erpVendorId]=V-10042' \
  --data-urlencode 'metadata[tier]=preferred'
```

On `GET /v1/products` the filter composes with `search` and `productGroupId`,
and `total` counts the filtered set. On `GET /v1/customers` it composes with
`customerNumber`, `externalSystemId`, and `parentCustomerId`.

The unfiltered customer, customer group, supplier, and vendor lists are cached
for 5 seconds. A filtered list is never cached, so a metadata write is visible
on the next filtered read. The product list is not cached.

## Lifecycle

Archiving a product keeps its metadata, and the archive response carries it.
Deleting a product deletes its metadata with it. A customer or supplier
deleted in the LedgerBee app loses its metadata the same way.

## Errors

| Condition | Error code | Fix |
| --- | --- | --- |
| A key is empty after trimming | `METADATA_KEY_REQUIRED` | Give every entry a non-empty key. |
| A key is longer than 128 characters | `METADATA_KEY_TOO_LONG` | Shorten the key. |
| A value is longer than 500 characters | `METADATA_VALUE_TOO_LONG` | Shorten the value, or store a reference to it. |
| More than 50 entries | `METADATA_TOO_MANY_ENTRIES` | Keep the map to 50 entries. |
| Two keys are the same after trimming and case-folding | `METADATA_DUPLICATE_KEY` | Send each key once. |
| More than 10 `metadata[key]` pairs on a list request | `METADATA_TOO_MANY_FILTERS` | Filter on at most 10 pairs and narrow further client-side. |
| A `__proto__` key in a create or update `metadata` map, or a `metadata[__proto__]` filter pair | `METADATA_KEY_RESERVED` | Use another key; this one cannot be a property of a JSON object. |

All are returned as 400 in the error envelope described in
[Errors](/guides/errors).
