# API design guidelines

These are the rules every endpoint in the Avallone API follows. Once you know them, you can predict how an unfamiliar endpoint behaves: its path, how it identifies records, what it returns, and what its errors look like.

The rules follow common practice from larger API providers. They draw on [Google's API Improvement Proposals](https://google.aip.dev/), the [Microsoft REST API Guidelines](https://github.com/microsoft/api-guidelines) and the [Zalando RESTful API Guidelines](https://opensource.zalando.com/restful-api-guidelines/).

## Scope

The guidelines cover every endpoint of the API, including bulk endpoints such as `PUT /sync`.

- Every new endpoint follows them from its first release.
- Some existing endpoints don't follow every rule yet. They are brought in line over time, and each change is announced in the [changelog](/changelog).
- Deprecated endpoints are not brought in line. They are retired through the [deprecation process](#deprecation) instead.

## Basics

- The base URL is `https://api.avallone.io`, and every path starts with `/api/v1`.
- The API is only served over HTTPS, with TLS 1.2 or later.
- Request and response bodies are JSON, encoded as UTF-8.
- Every request needs a Bearer token in the `Authorization` header. See [Authentication and permissions](#authentication-and-permissions).

## Paths

- Resources are plural nouns in lowercase kebab-case: `/companies`, `/related-companies`, `/company-document-types`.
- A single item sits under its collection: `/companies/{companyId}`.
- A resource that only exists as part of another one is nested under it: `/companies/{companyId}/documents/{documentId}`.
- Data that you read, but that isn't a field of the record, gets a sub-path named after what it returns. You read it with `GET`, for example `GET /companies/{companyId}/documents/{documentId}/content`.
- An action that isn't create, read, update or delete gets a sub-path named after the action, as a kebab-case verb. You call it with `POST`. For example, an action that archives a company would be `POST /companies/{companyId}/archive`.
- Path parameters are named after their resource, with an `Id` suffix: `{companyId}`, `{officerId}`, `{documentId}`, `{relationId}`. On `by-ref` paths, the parameter is `{referenceId}`.
- Query parameters are camelCase, like field names.

## Identifiers

Every record has an **internal ID**. It is a UUID that Avallone generates, and it never changes.

Companies and officers can also have a **reference ID**. That is the ID from your own system, which you send when you create or sync the record.

- A reference ID is 1 to 255 characters long. Any character is allowed except control characters.
- It is stored as you sent it, but compared without regard to case: `ACME-1` and `acme-1` are the same reference ID.
- It is unique per resource type within your tenant. Once a record is deleted, its reference ID can be used again.
- You can change it with `PATCH`. From then on, the old reference ID no longer finds the record.
- Don't put personal data, such as names, email addresses or national ID numbers, in a reference ID.

**In paths:**

- `{companyId}`, `{officerId}`, `{relationId}` and so on are always internal IDs.
- To address a record by your reference ID, use the `by-ref` form of the path: `/companies/by-ref/{referenceId}` or `/officers/by-ref/{referenceId}`. Every operation on the internal-ID path is also available on its `by-ref` form, including the sub-resources below it.
- A path uses `by-ref` at most once, for the top-level record. Anything below it is addressed by internal ID: `/companies/by-ref/{referenceId}/documents/{documentId}`. There is no path such as `/companies/by-ref/{referenceId}/officers/by-ref/{officerReferenceId}`.
- URL-encode a reference ID when you put it in a path.
- An internal ID in a path that isn't a valid UUID returns `400`.

**In request bodies,** when a body points at another record, you can identify it in either of two ways:

- `{resource}Id`, the internal ID, for example `companyId`;
- `{resource}ReferenceId`, your reference ID, for example `companyReferenceId`.

Send at least one. If you send both, they must point at the same record. Otherwise you get `400`.

If the record you point at doesn't exist, you get `404`, with a `not_found` entry in `errors` that points at the field. See [Telling 404s apart](#telling-404s-apart).

**In responses,** every record includes its `id`. Records that can have a reference ID also include `referenceId`, which is `null` when none is set.

## Methods

| Method | On a collection | On an item |
|---|---|---|
| `GET` | Lists the items | Returns the item |
| `POST` | Creates one or more items | — |
| `PUT` | Replaces the whole collection, where offered | Replaces the item, where offered |
| `PATCH` | — | Changes only the fields you send |
| `DELETE` | — | Deletes the item |

A method an endpoint doesn't support returns `405`.

Every method except `POST` is idempotent: sending the same request twice leaves the same result as sending it once. `POST` becomes safe to repeat when you send an [`Idempotency-Key`](#retries-and-idempotency).

### `POST` creates

- `POST` creates and never updates. If the record already exists, for example with the same reference ID, you get `409` and nothing changes.
- A body with one object creates one record. The response is `201`, with the record in the body and its path in the `Location` header.
- A body with a list of objects creates several records. The response is `201`, with the created records under `results`.

### `PATCH` updates

`PATCH` is the way to update a record. Every item that can be changed has a `PATCH`.

The body follows [JSON Merge Patch (RFC 7396)](https://www.rfc-editor.org/rfc/rfc7396). Send it as `application/merge-patch+json`; `application/json` is accepted too.

- Fields you leave out keep their value.
- To clear a field, send `null`.
- A nested object is merged field by field, by the same rules.
- A list is replaced as a whole. To add one item to a list, send the complete new list.

`PATCH` is safe even when we add fields you don't know about yet, because it only touches what you send.

### `PUT` on an item replaces it

New items get `PUT` only when it is useful to mirror a complete record from your own system. Otherwise, use `PATCH`. Where an item has `PUT`, it works like this:

- The body has the same fields as the `GET` response, so a record you read can be sent back as it is.
- **Writable fields you leave out are cleared.** Sending `null` clears a field too.
- **Sub-resources that have their own path are not touched.** A `PUT` on a company doesn't change its documents or its relations. Change those through their own paths.
- **`PUT` never creates.** A `PUT` to a record that doesn't exist returns `404`, also on a `by-ref` path. This way a typo in a reference ID can't create a duplicate. To create or update in one call, use a [bulk endpoint](#bulk-endpoints) such as `/sync`.

`PUT` has a cost when we add a field. A client that doesn't know the new field drops it when it reads the record, so its next `PUT` clears it. `If-Match` doesn't prevent this. It only stops you from overwriting a change someone else made (see [Concurrency](#concurrency)). If you use `PUT`, keep the fields you don't know when you read a record, and send them back unchanged. Better still, use `PATCH`.

### `PUT` on a collection replaces the collection

Some collections, such as a company's relations, offer `PUT`. Only collections nested under a record do, such as `/companies/{companyId}/related-companies`. A top-level collection, such as `/companies`, never offers `PUT`. To write many top-level records in one call, use a [bulk endpoint](#bulk-endpoints).

A collection `PUT` makes the stored collection match your request:

- Each item you send is matched to an existing item. It matches by `id` if you send one. Otherwise it matches by the key named on the endpoint's page.
- Matched items are updated in place and keep their `id`.
- New items are created.
- Existing items you didn't send are deleted.
- Sending `[]` empties the collection.

A collection `PUT` deletes whatever you leave out, so a list that was cut short on your side deletes data. Send `If-Match` with the `ETag` from your last `GET` of the collection.

### `DELETE`

- `DELETE` returns `204` when it deleted something.
- If nothing matches the ID, it returns `404`, also on a repeated call. So you can always tell a real delete from a call that changed nothing.

### Writes are all or nothing

A request either applies completely or changes nothing. This includes requests that touch several items, such as a `POST` with a list or a collection `PUT`. If one item fails, nothing is saved, and the error points at the item that failed.

[Bulk endpoints](#bulk-endpoints) are the one exception.

## Requests

**Content:**

- Request bodies are JSON, sent as `application/json`. `PATCH` also accepts `application/merge-patch+json`.
- A `POST` that creates several items, or a `PUT` that replaces a collection, sends the items as a bare JSON array. Response lists are wrapped in an object so they can gain fields later. These requests don't need that room, because their options go in query parameters. [Bulk endpoints](#bulk-endpoints) such as `/sync` have their own body shape, described on their page.
- File uploads use `multipart/form-data`. Each file comes with a metadata entry:
  - By default, entries are matched to files by position. The first entry describes the first file, and so on.
  - To match by name instead, add `fileName` to each entry, set to the `filename` of its file part. When `fileName` is present, it takes precedence over position.
  - Either every entry has `fileName` or none does. A `fileName` that matches no file, or more than one, returns `400`.
- Text is stored exactly as you send it. We don't trim it, change its case or normalize its Unicode, unless the field's description says so. For example, email addresses are trimmed and stored in lowercase.

**Limits:**

- A JSON request body can be up to 1.5 MB.
- Each endpoint's page names its other limits, such as the largest file it accepts or the most items a list can have.
- A body over the limit returns `413`. A list with too many items, or text that is too long, returns `400`.

**Fields:**

Every field has one of these behaviours, shown on the endpoint's page and in the OpenAPI specification:

| Behaviour | Meaning |
|---|---|
| Required | You must send it when you create the record. |
| Optional | You can leave it out. |
| Read-only | Avallone sets it, for example `id`, `url` and `createdAt`. It is ignored in requests, so a record you read can be sent back as it is. |
| Create-only | You can set it when you create the record, but not change it later. Sending a different value later returns `400`. |

- **A field the endpoint doesn't know returns `400`.** The error names the field. This catches typos, and fields that were removed.
- **A query parameter the endpoint doesn't know also returns `400`.**
- **An empty string is a value, not "no value".** To clear a field, send `null`. A required text field rejects an empty string.

## Responses

**Shape:**

- Successful responses are JSON, sent as `application/json`.
- A single record is a JSON object, with no wrapper.
- A list is always a JSON object with the items under `results`, never a bare array. A list that can grow without limit is also [paginated](#pagination), with its paging details under `metadata`. A JSON object can gain new fields later; a bare array can't.
- `POST`, `PUT` and `PATCH` return the records as they are after the change, in the same shape as `GET`.

**Fields every record has:**

| Field | Description |
|---|---|
| `id` | The internal ID |
| `url` | The path where you can `GET` the record, relative to the base URL, for example `/api/v1/companies/3f2a1c40-9c1e-4c7f-9c6b-2b0f7a5d1e88` |
| `createdAt` | When the record was created |
| `updatedAt` | When the record last changed |

Records that belong to a tenant also include `tenantId`: the ID of the tenant the record belongs to. It is always your own tenant. `tenantId` is read-only. It is ignored in requests, so a record you read can be sent back as it is.

Every field in a record's schema is present in every response. A field without a value is `null`, or `[]` for a list. It is never left out.

**Headers:**

- Every response carries an `X-Request-Id` header. Error bodies repeat it as `requestId`. Quote it when you contact support.
- Responses are compressed with gzip when the request sends `Accept-Encoding: gzip`.

**File downloads:**

A `content` sub-path, such as `GET /companies/{companyId}/documents/{documentId}/content`, returns the file itself:

- A record that has a file gives this path in `contentUrl`, next to its own `url`. Follow it rather than building the path yourself.
- The file is streamed in the response body. There is no redirect to another URL.
- `Content-Type` is the file's media type, and `Content-Length` its size in bytes.
- `Content-Disposition: attachment` carries the file name.
- The whole file is always returned. `Range` requests aren't supported.

## Status codes

| Code | When |
|---|---|
| `200 OK` | A read or update succeeded. The body holds the result. |
| `201 Created` | A `POST` created something. The body holds what was created. |
| `204 No Content` | A `DELETE` succeeded. There is no body. |
| `207 Multi-Status` | A [bulk endpoint](#bulk-endpoints) finished, and at least one item failed. The body holds a result per item. |
| `400 Bad Request` | The request is invalid: a malformed ID, a missing required field, a wrong type, an unknown field or parameter, an unknown enum value, or conflicting identifiers. |
| `401 Unauthorized` | The token is missing, malformed, expired or revoked. The response carries `WWW-Authenticate: Bearer`. |
| `403 Forbidden` | The token is valid, but it lacks the permission the endpoint needs, or its API user is deactivated. |
| `404 Not Found` | The path matches no endpoint, the record in the path doesn't exist, or a record named in the body doesn't exist. The error says which. |
| `405 Method Not Allowed` | The endpoint doesn't support the method. The `Allow` header lists the methods it supports. |
| `406 Not Acceptable` | The `Accept` header doesn't allow any type the endpoint can return. |
| `409 Conflict` | The request clashes with stored data: a duplicate, or a relation that would create an ownership cycle. Also returned, with `Retry-After`, while an earlier request with the same `Idempotency-Key` is still running. |
| `410 Gone` | The endpoint has been removed. The error names its replacement. |
| `412 Precondition Failed` | The `If-Match` header doesn't match the record's current `ETag`. Someone else changed it. |
| `413 Content Too Large` | The request body is over the endpoint's size limit. |
| `415 Unsupported Media Type` | The `Content-Type` isn't one the endpoint accepts. |
| `422 Unprocessable Content` | An `Idempotency-Key` was reused with a different request body. |
| `429 Too Many Requests` | You went over a [rate limit](#rate-limits). |
| `500 Internal Server Error` | Something failed on our side. |
| `502`, `503`, `504` | We are briefly unable to serve the request. Retry with backoff. |

## Errors

Every error response we send uses the [Problem Details format (RFC 9457)](https://www.rfc-editor.org/rfc/rfc9457), sent as `application/problem+json`:

```json
{
  "type": "https://api.avallone.io/problems/validation_failed",
  "title": "Validation failed",
  "status": 400,
  "detail": "2 fields are invalid.",
  "code": "validation_failed",
  "requestId": "0b8f7c2e-5d1a-4f0e-9a43-6c2d1e7b9f10",
  "errors": [
    {
      "pointer": "/incorporationCountryCode",
      "code": "invalid_format",
      "detail": "Must be an ISO 3166-1 alpha-2 country code."
    },
    {
      "pointer": "/name",
      "code": "required",
      "detail": "Must not be empty."
    }
  ]
}
```

| Field | Description |
|---|---|
| `type` | A URI that identifies the kind of problem. It always ends in the `code`. |
| `title` | A short summary of the kind of problem. It is the same every time the problem occurs. |
| `status` | The HTTP status code, repeated in the body. |
| `detail` | What went wrong in this request, written for people. |
| `code` | A stable, machine-readable code. See the table below. |
| `requestId` | The ID of the request. Quote it when you contact support. |
| `errors` | One entry per input that has a problem, such as an invalid field or a record named in the body that doesn't exist. `[]` when the problem isn't tied to a specific input. |

`title` and `detail` are in English.

Each entry in `errors` says where the problem is and what it is:

| Field | Description |
|---|---|
| `pointer` | Where the problem is in the request body, as a [JSON Pointer (RFC 6901)](https://www.rfc-editor.org/rfc/rfc6901). In a request with several items, it starts with the item's index, for example `/2/companyId`. |
| `parameter` | The path or query parameter with the problem. An entry has either `pointer` or `parameter`. |
| `code` | What is wrong: `required`, `invalid_type`, `invalid_format`, `invalid_value`, `out_of_range`, `too_long`, `too_many_items`, `unknown_field`, `create_only` or `not_found`. We may add codes. |
| `detail` | What is wrong, written for people. |

**Branch on `code`.** Show `detail` to people, but don't parse it: its wording can change. The codes are:

| `code` | Status | Meaning |
|---|---|---|
| `validation_failed` | `400` | Some input is invalid. `errors` says which. |
| `unauthenticated` | `401` | The token is missing, malformed, expired or revoked. |
| `permission_denied` | `403` | The token lacks the permission, or its API user is deactivated. |
| `endpoint_not_found` | `404` | The path matches no endpoint. `detail` names the method and path you sent. |
| `not_found` | `404` | A record in the path or body doesn't exist. `detail` names it, and for a record in the body, `errors` points at the field. |
| `method_not_allowed` | `405` | The endpoint doesn't support the method. |
| `not_acceptable` | `406` | The endpoint can't return a type the `Accept` header allows. |
| `already_exists` | `409` | A record with the same unique value already exists. |
| `ownership_cycle` | `409` | The relation would make a company own itself, directly or indirectly. |
| `idempotency_key_in_use` | `409` | An earlier request with the same `Idempotency-Key` is still running. |
| `gone` | `410` | The endpoint has been removed. `detail` names the replacement. |
| `precondition_failed` | `412` | The record changed since you read it. |
| `content_too_large` | `413` | The request body is over the size limit. |
| `unsupported_media_type` | `415` | The `Content-Type` isn't accepted. |
| `idempotency_key_reused` | `422` | The `Idempotency-Key` was used before with a different body. |
| `rate_limited` | `429` | You went over a rate limit. |
| `internal` | `500` | Something failed on our side. |
| `unavailable` | `503` | We are briefly unable to serve the request. |

A `502` or `504` can come from the network in front of the API, without a problem body. Handle those by their status code.

### Telling 404s apart

Every `404` has a problem body, so a mistyped path never looks like a missing record. Check `code` to see what wasn't found:

| What wasn't found | `code` | Example `detail` |
|---|---|---|
| The endpoint. The path is wrong, for example a typo. | `endpoint_not_found` | `No endpoint matches GET /api/v1/compnies.` |
| The record in the path. The path is right, but no record has that ID. | `not_found` | `Company 3f2a… doesn't exist.` |
| A record named in the body. `errors` points at the field. | `not_found` | `Company 3f2a… doesn't exist.` |

## Lists

### Pagination

Paginated lists are split into numbered pages. The query parameters are:

| Param | Default | Description |
|---|---|---|
| `page` | `0` | The page to return. The first page is `0`. A negative value returns `400`. |
| `limit` | `15` | Items per page, from `1` to `100`. A value outside that range returns `400`. |

A paginated response:

```json
{
  "results": [],
  "metadata": {
    "currentPage": 0,
    "totalPages": 5,
    "limit": 15,
    "nextPageUrl": "/api/v1/activities?page=1&limit=15"
  }
}
```

- `nextPageUrl` is `null` on the last page.
- `nextPageUrl` repeats your filters and sort. Follow it rather than building the next URL yourself.
- If you build the URL yourself, send the same filters and sort on every page.
- A page after the last one returns `200` with an empty `results`.
- A list with no matches returns `200` with an empty `results`. It never returns `404` or `204`.
- Pages are counted from the start of the list each time you ask for one. If records are added or deleted while you page through, the rest shift. A record can then appear on two pages, or on none.

### Filtering

- Filters are query parameters named after the field they filter on: `?incorporationCountryCode=DK`.
- Values of the same filter are combined with OR. Repeat the parameter to send several: `?incorporationCountryCode=DK&incorporationCountryCode=SE` returns companies in Denmark or Sweden.
- Different filters are combined with AND. `?incorporationCountryCode=DK&incorporationCountryCode=SE&type=parent` returns parent companies in Denmark or Sweden.
- There is no OR across different filters. To get companies in Denmark or of type `parent`, make two requests.
- An unknown value for an enum filter returns `400`. It never silently returns nothing.
- A filter on a record that doesn't exist, such as an unknown `companyId`, returns an empty list, not `404`.

These naming rules apply to lists of records. The activity log is a query over events, which have nested fields such as `actor.userId` and `resources[].type`, so its page names its own filters.

**Time ranges** use `from` and `to`:

- `from` is inclusive and `to` is exclusive. Back-to-back windows such as `from=09:00&to=10:00`, then `from=10:00&to=11:00` never return the same record twice.
- Both take an RFC 3339 timestamp, such as `2026-03-01T09:00:00Z`, or a calendar date. A date means midnight UTC at the start of that day. So `from=2026-03-01&to=2026-04-01` covers all of March.
- When a list can filter on more than one time field, the parameters are prefixed with the field: `createdFrom`, `createdTo`, `updatedFrom`, `updatedTo`.

**Finding changes.** Lists of records that can change accept `updatedFrom` and `updatedTo`, and can be sorted by `updatedAt`. To keep your system in sync, ask for the records updated since your last run, instead of reading everything again. Sort by `updatedAt` in ascending order.

> [!WARNING]
> Paging through changes isn't guaranteed to return every record. If a record changes while you page through, it moves to the end of the list and the records after it shift back by one. One of them can then be skipped.

Deleted records don't appear in lists, so `updatedFrom` can't tell you about them. To find deletions, read the [activity log](/activities). It records them as activity types such as `company-deleted` and `company-document-deleted`.

**Search.** Where a list offers free-text search, the parameter is `q`: `?q=acme`. The endpoint's page says which fields it searches.

### Sorting

- Every paginated list has a fixed default order, named on the endpoint's page. Records that tie on it are ordered by `id`, so the order is always stable.
- Where an endpoint lets you choose the order, use `sort` with a field name: `?sort=createdAt`. Prefix it with `-` for descending order: `?sort=-createdAt`. To sort by several fields, repeat the parameter.
- Sorting on a field the endpoint doesn't list returns `400`.

## Field names and formats

| Kind | Format | Example |
|---|---|---|
| Field names | camelCase | `incorporationCountryCode` |
| Abbreviations in names | Written as a word | `url`, `lei`, `nextPageUrl` |
| Lists | Plural name | `addresses`, `roles` |
| Yes/no fields | Boolean, named `is…` or `has…` | `isListed`, `hasLicenses` |
| References to other records | `{resource}Id` and `{resource}ReferenceId` | `companyId`, `companyReferenceId` |
| Internal IDs | UUID | `3f2a1c40-9c1e-4c7f-9c6b-2b0f7a5d1e88` |
| Points in time | RFC 3339 timestamp | `2026-03-14T09:21:44.512Z` |
| Calendar dates | ISO 8601 date, no time | `2026-03-14` |
| Countries | ISO 3166-1 alpha-2 | `DK` |
| Percentages | Number from `0` to `100`, at most 4 decimals | `51.25` |
| Enum values | lowercase snake_case | `parent`, `related_company` |

- Responses give timestamps in UTC, with milliseconds.
- A timestamp in a request must have seconds, and must end with `Z` for UTC or an offset such as `+01:00`, as RFC 3339 requires. A timestamp with neither returns `400`. It is never assumed to be UTC, because the API can't know which time zone the client meant.
- Use a calendar date for things that happen on a day, such as a date of birth, a role start date or an expiry date. Use a timestamp for things that happen at an instant, such as `createdAt`.

**Enum values** are lowercase snake_case. Error codes are enum values too, so they follow the same format.

- An enum value is a stable code, not display text. Where people need a readable name, the response has a `…Label` field next to it, for example `legalForm: "limited_liability_company"` and `legalFormLabel: "Limited Liability Company"`. The label's wording can change; the value can't.
- The activity log is the exception. Its activity types and resource types come from Avallone's internal activity log and keep that format, such as `document-uploaded` and `CompanyDocument`. Its page lists them.

## Concurrency

Two clients can change the same record at the same time. To stop one from silently overwriting the other, records carry a version tag, the `ETag`.

- `GET` on a record returns an `ETag` header. So does `GET` on a collection that offers `PUT`.
- Send that value back in an `If-Match` header on `PUT`, `PATCH` or `DELETE`. If the record changed since you read it, you get `412` and nothing changes. Read it again, reapply your change, and retry.
- `If-Match` is optional. We recommend it for every `PUT`, because a `PUT` built from an old copy overwrites newer changes.

> [!NOTE]
> Caching with the `ETag` isn't part of the API. Sending it in `If-None-Match` to get `304 Not Modified` isn't supported, so don't rely on it. We may add it in the future.

## Retries and idempotency

**`Idempotency-Key` makes a `POST` safe to retry.** Without it, a `POST` that timed out might have created the record, and retrying could create a second one. The header follows the [IETF Idempotency-Key draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/).

- Send a unique value, such as a new UUID, in the `Idempotency-Key` header of each `POST`. Use the same value when you retry that `POST`.
- We remember the key, per tenant, for 24 hours.
- A retry with the same key and the same body gets the original response again, with the same status and body. It also carries the header `Idempotent-Replayed: true`.
- The same key with a different body returns `422`.
- If the first request is still running, a retry returns `409` with a `Retry-After` header. Wait that long, then retry.

**When to retry:**

| Response | What to do |
|---|---|
| `429`, `503` | Wait for the time in `Retry-After`, then retry. |
| `502`, `504`, a timeout or a dropped connection | Retry with exponential backoff and random jitter. |
| `500` | Retry once or twice with backoff. If it keeps failing, contact support with the `requestId`. |
| Any other `4xx` | Don't retry. Change the request first. |

Only retry a request that is safe to repeat: `GET`, `PUT`, `PATCH`, `DELETE`, and `POST` with an `Idempotency-Key`. Because writes are all or nothing, a request that got an error response changed nothing. A request that timed out, or whose connection dropped, may or may not have been applied.

A retried `DELETE` can return `404` if the first attempt succeeded but its response was lost. Treat that `404` as success.

## Bulk endpoints

A bulk endpoint, such as `PUT /sync`, is a dedicated endpoint that takes many items in one request and handles each one on its own. One item failing doesn't stop the others. This is the only kind of write that isn't all or nothing.

A collection `PUT` or a `POST` with a list also writes several items, but it isn't a bulk endpoint. It follows the usual rule: if one item fails, nothing is saved.

- If the request as a whole is invalid, for example it isn't valid JSON, you get `400` and nothing is processed.
- Otherwise every item gets a result. The response is `200` if every item succeeded, and `207` if at least one failed.
- Results are in the same order as the items you sent:

```json
{
  "results": [
    {
      "index": 0,
      "referenceId": "acme-inc",
      "status": "created",
      "id": "3f2a1c40-9c1e-4c7f-9c6b-2b0f7a5d1e88",
      "error": null
    },
    {
      "index": 1,
      "referenceId": "globex",
      "status": "failed",
      "id": null,
      "error": {
        "type": "https://api.avallone.io/problems/validation_failed",
        "title": "Validation failed",
        "status": 400,
        "detail": "1 field is invalid.",
        "code": "validation_failed",
        "errors": [
          { "pointer": "/name", "code": "required", "detail": "Must not be empty." }
        ]
      }
    }
  ]
}
```

| Field | Description |
|---|---|
| `index` | The item's position in the request, from `0` |
| `referenceId` | The item's reference ID, if it has one |
| `status` | `created`, `updated`, `unchanged`, `deleted` or `failed` |
| `id` | The internal ID of the record, or `null` if the item failed before a record existed |
| `error` | Why the item failed, in the [error format](#errors), with pointers relative to the item. `null` when it succeeded. |

## Rate limits

Requests are limited per tenant.

- Every response carries these headers:

  | Header | Description |
  |---|---|
  | `X-RateLimit-Limit` | How many requests the current window allows |
  | `X-RateLimit-Remaining` | How many are left in the current window |
  | `X-RateLimit-Reset` | When the window resets, in seconds since the Unix epoch |

- If you go over the limit, you get `429` with a `Retry-After` header saying how many seconds to wait.

The IETF is working on standard `RateLimit` headers, but the draft still changes between revisions. We will add them once it is final, alongside these.

## Authentication and permissions

- Every request needs a Bearer token: `Authorization: Bearer <token>`. See [Authentication](/authentication).
- Never send a token in a query string. URLs end up in logs.
- Treat a token as an opaque string. Don't decode it or rely on what it contains.
- Each endpoint needs one permission, named on its page. A token without that permission gets `403`.
- Tokens expire after 1 year by default. When you create a token, you can choose an expiry from 3 months to 3 years instead.
- You can have several active tokens. To rotate one without downtime, create the new token, switch your client to it, then revoke the old one.

> [!NOTE]
> How a token gets its permissions, and when a removed permission stops working, is still being decided. Until then, see [Authentication](/authentication#permissions) for how it works today.

## Versions and changes

The major version is in the path: `/api/v1`. We evolve `v1` in place, under the rules below, instead of starting a `v2`.

Every release has a version number in the [changelog](/changelog), and the OpenAPI specification shows the current one in `info.version`.

**These changes are non-breaking.** We make them without notice and list them in the changelog:

- new endpoints;
- new optional request fields (for items you replace with `PUT`, see [what a new field means for `PUT`](#put-on-an-item-replaces-it));
- new response fields;
- new enum values;
- new error codes;
- changes to the wording of `title`, `detail` and descriptions.

Your client should ignore response fields it doesn't know. It should also handle an enum value or error code it doesn't know without failing. For an unknown error code, fall back to the HTTP status.

**These changes are breaking:**

- removing or renaming an endpoint, a field or an enum value;
- changing a field's type or format;
- adding a required request field, or making an optional one required;
- making a response field nullable that never was;
- rejecting a value that used to be accepted, for example by adding a maximum length;
- changing a default value, a page size or a sort order;
- changing which status code or error code a situation returns;
- changing what an operation does with the data you send;
- requiring a different permission for an endpoint.

**How we make a breaking change.** We avoid breaking changes. When one is needed, it follows the [deprecation process](#deprecation):

1. We announce the change in the changelog, with the date it takes effect.
2. We contact every tenant whose traffic it affects.
3. The change takes effect at least 6 months after the announcement, or earlier if every affected tenant agrees to an earlier date.

We group breaking changes so they take effect together, on at most two dates a year. You update your integration once per date, instead of for each change.

On its effective date, a breaking change applies to the existing path. This is the only way an endpoint's behaviour changes under the same path, and it is always announced first.

## Deprecation

We deprecate something when a better way exists and we want you to move to it. We can deprecate any of these:

- an endpoint;
- a request or response field;
- an enum value;
- a behaviour, such as an endpoint accepting a field it shouldn't.

A deprecated item works exactly as before until the date it is removed.

### How you find out

When we deprecate something, we announce it in all of these places at once:

- **The [changelog](/changelog).** The entry says what is deprecated, why, what replaces it, and the date it will be removed.
- **The OpenAPI specification.** The item is marked `deprecated`. Its description names the replacement and the removal date.
- **Response headers**, on every call to a deprecated endpoint:
  - `Deprecation` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)): when the endpoint was deprecated.
  - `Sunset` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)): when it will be removed.
  - `Link` with `rel="deprecation"`: the changelog entry.
- **A direct message** to every tenant that used it in the last 90 days.

The headers describe whole endpoints. A deprecated field, enum value or behaviour is announced through the changelog, the specification and direct messages.

We recommend logging the `Deprecation` and `Sunset` headers in your client, so a deprecation can't go unnoticed.

### Timeline

1. **Announcement.** The item is marked deprecated. It keeps working unchanged, and new features go to its replacement.
2. **Notice period.** At least 6 months pass between the announcement and the removal date.
3. **Reminder.** One month before the removal date, we contact every tenant that is still using it.
4. **Removal.** This happens on or after the removal date, once the item has gone unused for 30 days. If a tenant is still using it, we contact them and agree on a new date.

### Rules that apply while something is deprecated

- If you send a deprecated field together with its replacement, both must have the same value. Otherwise you get `400`, as with `{resource}Id` and `{resource}ReferenceId`.
- A deprecated response field keeps being returned, with the same value, until it is removed.
- A deprecated enum value is still accepted in requests. Responses switch to the replacement value on the removal date.

### After removal

- **A removed endpoint returns `410 Gone`.** The error names the replacement.
- **A request that still sends a removed field with a value gets `400`**, with an `unknown_field` error naming it. We never silently ignore a value.
- **A removed field sent as `null` is ignored.** A client that reads a record and sends it back in a `PUT`, with a model that still has the old field, keeps working.

## OpenAPI specification

The specification is at `https://api.avallone.io/docs/api`, and as JSON at `https://api.avallone.io/docs/api-json`. It uses OpenAPI 3.1, and describes every endpoint with:

- the base URL in `servers`, and the current release in `info.version`;
- each endpoint grouped under a tag for its resource;
- the property names exactly as they appear in the JSON;
- each field's behaviour: `required`, `readOnly`, and create-only fields named in their description;
- the request and response schemas, including the error schema for every error status;
- every status code the endpoint can return, and the headers described on this page;
- an example for every request and response, which passes the endpoint's own validation.

Names follow fixed patterns, so generated clients get predictable class and method names:

| What | Pattern | Examples |
|---|---|---|
| `operationId` | Verb and resource, camelCase. The verbs are `list`, `get`, `create`, `update` (`PATCH`), `replace` (`PUT`) and `delete`. | `listCompanies`, `getCompany`, `updateCompany` |
| Record schemas | The resource, singular, PascalCase | `Company`, `RelatedCompany` |
| Request schemas | Verb and resource, with `Request` | `CreateCompanyRequest`, `UpdateCompanyRequest` |
| List schemas | Resource and `List` | `CompanyList` |
| Error schema | `Problem` | `Problem` |

If the specification and an endpoint's behaviour disagree, that is a bug. Please tell us.
