# Booster database

> Read and write the records of a booster panel's database from your own server with an API key.

A booster panel is a small web application built in AYETO Booster Studio. A panel can
have its own database, and the endpoints on this page let your server read and change
that database with an API key: import data into a panel, sync it with another system,
or process what the panel's users entered.

All endpoints are under `/api/v3/booster/database/private/`. Panels themselves reach
their database through a separate access path that is not covered here.

## Concepts

### Panels and their database

Each panel has at most one database. It exists when the panel's **Database access**
setting (panel details in the app) is **Public database** or **Private database**;
with **No database** every endpoint on this page answers `403`. Both public and
private mode can be used through this API. The database is created on first use and
is deleted together with the panel.

Every request names the panel in the `panel-id` header. The panel's ID is shown, with
a copy button, at the top of the panel details in the app.

### Collections and records

A database holds **collections**, and a collection holds **records**. Collections do
not need to be created: a collection exists as soon as it has a record. Collection
names may contain only letters, digits and underscores (`orders`, `contact_requests`).

A record has exactly two fields:

| Field | Type | Description |
|---|---|---|
| `key` | UUID | Identifier of the record, unique across the databases of all panels (not only within its collection). |
| `value` | any | The data you stored. Any JSON value is accepted; use an object if you want to filter or sort on its fields. |

Records carry no other metadata: responses do not include the collection name,
timestamps or the author. If you need any of these, store them in `value`.

Endpoints that work with one record (`get`, `update`, `delete` and their batch
variants) address it by `key` alone; the collection is taken from the stored record.
Endpoints that work with a whole collection (`create`, `find`, `count`, locks) take
the collection name in the body.

```json
{
  "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
  "value": {
    "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
    "status": "open",
    "priority": 3,
    "created_at": "2026-10-01T09:15:00Z"
  }
}
```

### Who can access a panel's data

Requests act as the user who owns the API key. That user must:

- own the panel (be its creator, or a member of the group that owns it, for example
  the administrators of an organization that received the panel from a partner), or
- have the panel shared with them, directly or through a group they belong to.

Owners and users the panel is shared with (read or write sharing) can read and write
the database, in both database modes: writing to the database counts as using the
panel, not editing it (changing the panel itself still needs write sharing).
Membership in the panel's organization does not by itself give access, and neither do
AYETO administrator accounts. The API key must also carry the matching
[scope](authentication.md#scopes):

| Scope | Endpoints |
|---|---|
| `ayeto.booster.database` | `get`, `find`, `count` |
| `ayeto.booster.database.write` | `create`, `create-many`, `update`, `update-many`, `delete`, `delete-many`, `lock/acquire`, `lock/release` |

The write scope does not include the read scope; a key that reads and writes needs
both.

Writes made through this API are writes to the panel's database like any other: they
start workflows with a booster record trigger on the panel and run the panel's
webhook and e-mail notification modules.

### Collection rules

A panel can define collection rules that decide what the panel's anonymous visitors
may do. Rules apply only in **public database** mode; a panel in private mode ignores
them, and every endpoint on this page works on all its collections.

For a panel in public mode, only one part of the rules matters for requests on this
page:

- An operation set to **none** for a collection (or for the `*` rule that covers it)
  is disabled for everyone, including this API. The request fails with `403`.
- Every other level (public, secret, private) allows API requests.
- A collection with no matching rule is fully accessible to the API.

The write constraints of a rule (validation schema, maximum value size, maximum
number of records) are **not** applied to writes made through this API. Validate the
data on your side before you write it.

### Limits

| Limit | Value |
|---|---|
| Records returned by one `find` | up to 1000 when paging (`limit_to` is at most `1000`, `limit_from` lower than `1000`); all matching records when no limits are sent |
| Filter conditions in one query | 1000, including conditions inside `AND` / `OR` |
| Nesting of `AND` / `OR` | 100 levels |
| Values in one `in` condition | 100 |
| String filter value | 1000 characters |
| Lock time to live | more than 0.1 s, default 3600 s |

The batch endpoints have no fixed maximum number of items, but each item is written
separately (see [Create many records](#create-many-records)); keep batches to a few
hundred items so that a request finishes well within your HTTP timeout.

## Querying records

`find` and `count` take an optional `params` object. Its fields follow the general
[query conventions](conventions.md#querying-lists), with the differences listed here.

| Field | Type | Required | Description |
|---|---|---|---|
| `filters` | array | no | Conditions, all of which must match. See [Filters](conventions.md#filters) and below. |
| `sort_key` | string | no | Field to sort by: `key` or `value.<field>`. |
| `sort_order` | integer | no | `0` ascending (default when `sort_key` is set), `1` descending. |
| `limit_from` | integer | no | Index of the first record to return, from `0`, lower than `1000`. |
| `limit_to` | integer | no | Index after the last record to return (not a count), at most `1000`. When only `limit_from` is sent, it is `limit_from + 100` (at most `1000`). |

### Fields you can filter and sort on

Only two kinds of field names are accepted:

- `key`, the record key (compare it with a UUID string).
- `value.<path>`, a field inside the record value, with dots for nested objects:
  `value.status`, `value.customer.email`.

Any other name fails with `422`. Records whose value lacks the field (or is not an
object) simply do not match.

A condition is `[field, operator, value]` with the operators `==`, `!=`, `<`, `>`,
`<=`, `>=`, `regex` (always case-insensitive) and `in` (the value is a list, any of
which may match). Conditions can be combined with `{"AND": [...]}` and
`{"OR": [...]}`; each of these needs at least two conditions and can be nested.

```json
{
  "collection": "tickets",
  "params": {
    "filters": [
      ["value.status", "in", ["open", "waiting"]],
      {"OR": [
        ["value.priority", ">=", 3],
        ["value.customer.email", "regex", "@example\\.com$"]
      ]}
    ],
    "sort_key": "value.created_at",
    "sort_order": 1,
    "limit_from": 0,
    "limit_to": 50
  }
}
```

Things to keep in mind when filtering on values:

- **Types must match.** `["value.priority", ">=", 3]` does not match a stored
  `"priority": "3"`. Store numbers as numbers and dates as ISO 8601 strings or as
  numbers, so that `<` and `>` compare them correctly.
- **UUID-like strings stay strings** in `value.*` conditions, so a UUID you stored as
  text is found as text.
- **The characters `<`, `>`, `"` and `'` in a string filter value** are escaped before
  the comparison, so a condition on text that contains them does not match. A string
  filter value must also not start with `$`.

### Sorting and paging

Without `sort_key`, records come newest first. With `sort_key`, they are sorted by
that field.

`limit_from` and `limit_to` are positions in the result, so the second page of 50
is `"limit_from": 50, "limit_to": 100`. Because `limit_to` cannot exceed `1000`,
paging with limits reaches only the first 1000 matching records. To go further,
page by value: sort on a field and filter on the last value you received, for
example `["value.created_at", "<", "2026-09-30T12:00:00Z"]` with
`"sort_key": "value.created_at", "sort_order": 1`.

If you send neither `limit_from` nor `limit_to`, `find` returns **all** matching
records. Use that only for collections you know are small.

`count` applies the filters and ignores sorting and paging: it returns the total
number of matching records.

## Common request headers and errors

Every endpoint on this page needs these headers:

| Header | Description |
|---|---|
| `uni-api-key` | Your API key. See [Authentication](authentication.md). |
| `panel-id` | ID of the panel whose database you access. |
| `Content-Type` | `application/json` |

Errors are JSON of the form `{"detail": "..."}` (see [Errors](conventions.md#errors)).
These can come from every endpoint:

| Status | `detail` | Cause |
|---|---|---|
| `401` | `API key not provided` / `API key is invalid` | The key is empty, unknown, disabled or lacks the endpoint's scope. |
| `403` | `permission denied` | You neither own the panel nor have it shared with you. |
| `403` | `database access is not allowed for this panel` | The panel's database access is set to **No database**. |
| `403` | `operation '<operation>' is disabled for collection '<collection>'` | A collection rule of a panel in public mode sets this operation to **none**. The operation is `create`, `read`, `update`, `delete`, `count` or `lock`. |
| `404` | `panel not found` | No panel with this ID, or the panel is disabled. |
| `422` | validation error | The `panel-id` header is missing or not a UUID, or the body does not match the request model. |
| `422` | `Collection name can only contain alphanumeric characters and underscores` | Invalid collection name (`Collection cannot be empty` for an empty one). |
| `429` | rate limit message | Too many requests; wait for the time in the `Retry-After` header. See [Rate limits](conventions.md#rate-limits). |
| `500` | server error | Unexpected failure; retry later. |

## Find records

Returns the records of a collection that match the query.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/find` |
| Scope | `ayeto.booster.database` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `collection` | string | yes | Collection name. |
| `params` | object | no | Filters, sorting and paging; see [Querying records](#querying-records). Without it, all records of the collection are returned, newest first. |

### Response

An array of [records](#collections-and-records) (`key`, `value`). An unknown or empty
collection returns `[]`.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | `Field '<field>' is not allowed for filtering or sorting` | A filter or `sort_key` names something other than `key` or `value.<path>`. |
| `422` | `Each filter condition must be a list of [field, operator, value]` | Malformed condition. |
| `422` | `Operator '<operator>' is not allowed` | Unknown operator. |
| `422` | `'<AND/OR>' operator requires a list of at least 2 conditions` | The value of `AND` / `OR` is not a list of at least two conditions. |
| `422` | `Logical filter must have exactly one key ('AND' or 'OR')` | Malformed logical condition, e.g. `{}` or an object with several keys. |
| `422` | `limit_from must be lower than 1000` | `limit_from` of 1000 or more (paging reaches only the first 1000 records). |
| `422` | `Maximum number of filter conditions is 1000 (including conditions inside AND/OR)` | Too many conditions. |
| `422` | `sort_order must be 0 (ASC) or 1 (DESC)` | Invalid `sort_order`. |
| `422` | `limit_to cannot exceed 1000` | `limit_to` above 1000. |
| `422` | `limit_from cannot be greater than limit_to` | Inverted range. |

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/find" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{
    "collection": "tickets",
    "params": {
      "filters": [["value.status", "==", "open"]],
      "sort_key": "value.priority",
      "sort_order": 1,
      "limit_from": 0,
      "limit_to": 20
    }
  }'
```

```json
[
  {
    "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
    "value": {
      "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
      "status": "open",
      "priority": 3,
      "created_at": "2026-10-01T09:15:00Z"
    }
  },
  {
    "key": "c9f0f895-fb98-4b91-8f2a-6d3e5c1b0a47",
    "value": {
      "customer": {"name": "Petr Svoboda", "email": "petr@example.org"},
      "status": "open",
      "priority": 1,
      "created_at": "2026-10-01T11:40:00Z"
    }
  }
]
```

## Count records

Returns the number of records of a collection that match the filters.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/count` |
| Scope | `ayeto.booster.database` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `collection` | string | yes | Collection name. |
| `params` | object | no | Same object as for [Find records](#find-records). Only `filters` affect the result; sorting and paging are validated but ignored. |

### Response

An integer.

### Errors

The query errors of [Find records](#find-records).

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/count" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"collection": "tickets", "params": {"filters": [["value.status", "==", "open"]]}}'
```

```json
42
```

## Get a record

Returns one record by its key.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/get` |
| Scope | `ayeto.booster.database` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `key` | UUID | yes | Key of the record. |

### Response

The [record](#collections-and-records).

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `404` | `Database record not found` | This panel's database has no record with the key. |

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/get" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70"}'
```

```json
{
  "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
  "value": {
    "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
    "status": "open",
    "priority": 3,
    "created_at": "2026-10-01T09:15:00Z"
  }
}
```

## Create a record

Adds a record to a collection.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/create` |
| Scope | `ayeto.booster.database.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `collection` | string | yes | Collection name (letters, digits, underscores). The collection is created with its first record. |
| `value` | any | no | The data to store. Omitted means `null`. |
| `key` | UUID | no | Key of the new record. Generated when omitted; it must not be used by any existing record of any panel (otherwise `422`). |

### Response

The created [record](#collections-and-records), including its `key`.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `422` | `a record with key '<key>' already exists` | The key you sent is used by an existing record (of this or another panel). |
| `500` | `error creating database record` | The record could not be stored (rarely, two requests created the same key at the same moment). |

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/create" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{
    "collection": "tickets",
    "value": {
      "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
      "status": "open",
      "priority": 3,
      "created_at": "2026-10-01T09:15:00Z"
    }
  }'
```

```json
{
  "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
  "value": {
    "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
    "status": "open",
    "priority": 3,
    "created_at": "2026-10-01T09:15:00Z"
  }
}
```

## Create many records

Adds several records, possibly to different collections.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/create-many` |
| Scope | `ayeto.booster.database.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

The body is a JSON **array** of objects of the same shape as for
[Create a record](#create-a-record) (`collection`, `value`, optional `key`).

The items are written one by one, in order. The batch is not atomic: if an item
fails, the request returns the error, the records before it stay created and the
items after it are not written. An item whose `key` already exists stops the batch
with `422`. Before retrying a failed batch, check what was
written, or send your own keys so that you can tell which items exist.

### Response

An array of the created records, in request order.

### Errors

The errors of [Create a record](#create-a-record), for the first item that failed.

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/create-many" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '[
    {"collection": "products", "key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "value": {"sku": "MUG-01", "name": "Mug", "quantity": 12}},
    {"collection": "products", "key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54", "value": {"sku": "TEE-M", "name": "T-shirt M", "quantity": 40}}
  ]'
```

```json
[
  {"key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "value": {"sku": "MUG-01", "name": "Mug", "quantity": 12}},
  {"key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54", "value": {"sku": "TEE-M", "name": "T-shirt M", "quantity": 40}}
]
```

## Update a record

Replaces the value of a record.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/update` |
| Scope | `ayeto.booster.database.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `key` | UUID | yes | Key of the record. |
| `value` | any | no | The new value. It replaces the stored value completely; fields you leave out are removed. Omitted means `null`. |

To change a single field, read the record, change the field and send the whole value
back. If other clients may write the same record at the same time, do this under a
[lock](#locks).

### Response

The updated [record](#collections-and-records).

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `404` | `Database record not found` | This panel's database has no record with the key. |

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/update" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
    "value": {
      "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
      "status": "closed",
      "priority": 3,
      "created_at": "2026-10-01T09:15:00Z"
    }
  }'
```

```json
{
  "key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70",
  "value": {
    "customer": {"name": "Jana Nováková", "email": "jana@example.com"},
    "status": "closed",
    "priority": 3,
    "created_at": "2026-10-01T09:15:00Z"
  }
}
```

## Update many records

Replaces the values of several records.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/update-many` |
| Scope | `ayeto.booster.database.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

The body is a JSON **array** of objects of the same shape as for
[Update a record](#update-a-record) (`key`, `value`). Items are written one by one, in
order, and the batch is not atomic: a failing item stops the request, the items before
it stay updated.

### Response

An array of the updated records, in request order.

### Errors

The errors of [Update a record](#update-a-record), for the first item that failed.

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/update-many" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '[
    {"key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "value": {"sku": "MUG-01", "name": "Mug", "quantity": 10}},
    {"key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54", "value": {"sku": "TEE-M", "name": "T-shirt M", "quantity": 37}}
  ]'
```

```json
[
  {"key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "value": {"sku": "MUG-01", "name": "Mug", "quantity": 10}},
  {"key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54", "value": {"sku": "TEE-M", "name": "T-shirt M", "quantity": 37}}
]
```

## Delete a record

Deletes one record.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/delete` |
| Scope | `ayeto.booster.database.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `key` | UUID | yes | Key of the record. |

### Response

`null`.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `404` | `Database record not found` | This panel's database has no record with the key (also when it was already deleted). |

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/delete" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"key": "8f14e45f-ceea-467a-9b1c-4e1f2a6b3c70"}'
```

## Delete many records

Deletes several records.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/delete-many` |
| Scope | `ayeto.booster.database.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

The body is a JSON **array** of objects with a `key` field, as for
[Delete a record](#delete-a-record). Records are deleted one by one, in order; a failing
item (for example a key that does not exist) stops the request, and the records before
it stay deleted.

### Response

`null`.

### Errors

The errors of [Delete a record](#delete-a-record), for the first item that failed.

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/delete-many" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '[
    {"key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30"},
    {"key": "5a9d3f2e-8c1b-4a6e-b7d0-3e2f1c9a8b54"}
  ]'
```

## Locks

Locks let several clients (your servers, the panel itself) take
turns on the same data. A lock has a time to live and is one of two kinds:

- **Record lock**: `collection` and `key`. Use the record's actual collection; a
  lock taken under another collection name does not protect the record.
- **Collection lock**: `collection` without `key`.

Locks are **advisory**: the server does not check them. `get`, `find`, `count`,
`create`, `update`, `delete` and their batch variants go through whether a record or
collection is locked or not. A lock only makes other `lock/acquire` calls for the same
lock wait, so it protects data only when every client that writes it takes the lock
first; coordinating that is up to the panel and your code.

- **Record and collection locks are independent.** Holding a collection lock does not
  stop anyone from taking a record lock in it, and the other way round.
- **Anyone may release a lock.** `release` frees the lock whoever took it, and a
  second `acquire` of a lock you already hold waits like anyone else's. Release a lock
  only from the code that acquired it.
- **Locks expire.** Every lock is freed automatically after its `ttl`, even if it was
  never released. Choose a TTL longer than your work takes, but short enough that a
  crashed client does not block others for long.

Locking is the `lock` operation of [collection rules](#collection-rules); both
endpoints need the write scope and the same access to the panel as writing.

## Acquire a lock

Takes a record or collection lock, waiting until it is free.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/lock/acquire` |
| Scope | `ayeto.booster.database.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `collection` | string | yes | Collection name. |
| `key` | UUID | no | Record key. Omit it to take the collection lock. |
| `acquire_timeout` | number | no | Seconds to wait for the lock. `0` fails at once when the lock is taken. When omitted, the request waits as long as it takes, up to the remaining TTL of the current holder; always send a value shorter than your HTTP timeout. |
| `ttl` | number | no | Seconds after which the lock is released automatically. Must be greater than `0.1`. Default `3600`. |

### Response

`null`, once the lock is yours.

### Errors

| Status | `detail` | Cause |
|---|---|---|
| `423` | `Failed to acquire lock for collection '<collection>' and key '<key>'` | The lock stayed taken for `acquire_timeout` seconds (`<key>` is `None` for a collection lock). Retry later. |
| `422` | validation error | `ttl` is `0.1` or less. |

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/lock/acquire" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"collection": "products", "key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", "acquire_timeout": 10, "ttl": 30}'
```

## Release a lock

Frees a record or collection lock, whoever holds it.

| | |
|---|---|
| Endpoint | `POST /api/v3/booster/database/private/lock/release` |
| Scope | `ayeto.booster.database.write` |
| Rate limit | [default](conventions.md#rate-limits) |

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `collection` | string | yes | Collection name, as sent to `lock/acquire`. |
| `key` | UUID | no | Record key, as sent to `lock/acquire`. Omit it for the collection lock. |

### Response

`null`. Releasing a lock that is not taken (or has expired) also succeeds.

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/booster/database/private/lock/release" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "panel-id: 3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90" \
  -H "Content-Type: application/json" \
  -d '{"collection": "products", "key": "0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30"}'
```

### Read-modify-write under a lock

`update` replaces the whole value, so two clients that each read a record, change it
and write it back can overwrite each other. Take the record lock around the whole
sequence and release it in a `finally` block; every client writing the record must do
the same:

```python
import os
import time

import requests

BASE_URL = "https://ayeto.ai/api/v3/booster/database/private"
HEADERS = {
    "uni-api-key": os.environ["AYETO_API_KEY"],
    "panel-id": "3d2c5a8e-7b41-4f0e-9a6d-2c8b1e4f7a90",
}


def call(path: str, body):
    response = requests.post(f"{BASE_URL}/{path}", json=body, headers=HEADERS, timeout=30)
    response.raise_for_status()
    return response.json()


def change_stock(key: str, delta: int, attempts: int = 3) -> dict:
    """Add `delta` to the quantity of a product record without losing concurrent changes."""
    lock = {"collection": "products", "key": key}
    for attempt in range(attempts):
        try:
            # wait at most 10 s; the lock frees itself after 30 s if this process dies
            call("lock/acquire", {**lock, "acquire_timeout": 10, "ttl": 30})
            break
        except requests.HTTPError as e:
            if e.response.status_code != 423 or attempt == attempts - 1:
                raise
            time.sleep(2)

    try:
        record = call("get", {"key": key})
        value = record["value"]
        value["quantity"] = value.get("quantity", 0) + delta
        return call("update", {"key": key, "value": value})
    finally:
        call("lock/release", lock)


print(change_stock("0b6e8c1a-5d2f-4e3b-9a7c-1f4d6e8a2b30", -2))
```
