API design guidelines
How every endpoint of the Avallone API works, and how we change and retire endpoints.
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, the Microsoft REST API Guidelines and the Zalando 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.
- Deprecated endpoints are not brought in line. They are retired through the deprecation process 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
Authorizationheader. See 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 exampleGET /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 bePOST /companies/{companyId}/archive. - Path parameters are named after their resource, with an
Idsuffix:{companyId},{officerId},{documentId},{relationId}. Onby-refpaths, 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-1andacme-1are 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-refform of the path:/companies/by-ref/{referenceId}or/officers/by-ref/{referenceId}. Every operation on the internal-ID path is also available on itsby-refform, including the sub-resources below it. - A path uses
by-refat 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 examplecompanyId;{resource}ReferenceId, your reference ID, for examplecompanyReferenceId.
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.
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.
POST creates
POSTcreates and never updates. If the record already exists, for example with the same reference ID, you get409and nothing changes.- A body with one object creates one record. The response is
201, with the record in the body and its path in theLocationheader. - A body with a list of objects creates several records. The response is
201, with the created records underresults.
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). 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
GETresponse, so a record you read can be sent back as it is. - Writable fields you leave out are cleared. Sending
nullclears a field too. - Sub-resources that have their own path are not touched. A
PUTon a company doesn't change its documents or its relations. Change those through their own paths. PUTnever creates. APUTto a record that doesn't exist returns404, also on aby-refpath. This way a typo in a reference ID can't create a duplicate. To create or update in one call, use a bulk endpoint 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). 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.
A collection PUT makes the stored collection match your request:
- Each item you send is matched to an existing item. It matches by
idif 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
DELETEreturns204when 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 are the one exception.
Requests
Content:
- Request bodies are JSON, sent as
application/json.PATCHalso acceptsapplication/merge-patch+json. - A
POSTthat creates several items, or aPUTthat 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 such as/synchave 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
fileNameto each entry, set to thefilenameof its file part. WhenfileNameis present, it takes precedence over position. - Either every entry has
fileNameor none does. AfileNamethat matches no file, or more than one, returns400.
- 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, returns400.
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, with its paging details undermetadata. A JSON object can gain new fields later; a bare array can't. POST,PUTandPATCHreturn the records as they are after the change, in the same shape asGET.
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-Idheader. Error bodies repeat it asrequestId. 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 ownurl. Follow it rather than building the path yourself. - The file is streamed in the response body. There is no redirect to another URL.
Content-Typeis the file's media type, andContent-Lengthits size in bytes.Content-Disposition: attachmentcarries the file name.- The whole file is always returned.
Rangerequests 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 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. |
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), sent as application/problem+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). 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:
{
"results": [],
"metadata": {
"currentPage": 0,
"totalPages": 5,
"limit": 15,
"nextPageUrl": "/api/v1/activities?page=1&limit=15"
}
}nextPageUrlisnullon the last page.nextPageUrlrepeats 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
200with an emptyresults. - A list with no matches returns
200with an emptyresults. It never returns404or204. - 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=SEreturns companies in Denmark or Sweden. - Different filters are combined with AND.
?incorporationCountryCode=DK&incorporationCountryCode=SE&type=parentreturns 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, not404.
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:
fromis inclusive andtois exclusive. Back-to-back windows such asfrom=09:00&to=10:00, thenfrom=10:00&to=11:00never 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. Sofrom=2026-03-01&to=2026-04-01covers 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. 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
sortwith 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
Zfor UTC or an offset such as+01:00, as RFC 3339 requires. A timestamp with neither returns400. 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
…Labelfield next to it, for examplelegalForm: "limited_liability_company"andlegalFormLabel: "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-uploadedandCompanyDocument. 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.
GETon a record returns anETagheader. So doesGETon a collection that offersPUT.- Send that value back in an
If-Matchheader onPUT,PATCHorDELETE. If the record changed since you read it, you get412and nothing changes. Read it again, reapply your change, and retry. If-Matchis optional. We recommend it for everyPUT, because aPUTbuilt 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.
- Send a unique value, such as a new UUID, in the
Idempotency-Keyheader of eachPOST. Use the same value when you retry thatPOST. - 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
409with aRetry-Afterheader. 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
400and nothing is processed. - Otherwise every item gets a result. The response is
200if every item succeeded, and207if at least one failed. - Results are in the same order as the items you sent:
{
"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, 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-LimitHow many requests the current window allows X-RateLimit-RemainingHow many are left in the current window X-RateLimit-ResetWhen the window resets, in seconds since the Unix epoch -
If you go over the limit, you get
429with aRetry-Afterheader 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. - 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 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, 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 forPUT); - new response fields;
- new enum values;
- new error codes;
- changes to the wording of
title,detailand 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:
- We announce the change in the changelog, with the date it takes effect.
- We contact every tenant whose traffic it affects.
- 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. 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:
- 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
- Announcement. The item is marked deprecated. It keeps working unchanged, and new features go to its replacement.
- Notice period. At least 6 months pass between the announcement and the removal date.
- Reminder. One month before the removal date, we contact every tenant that is still using it.
- 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}Idand{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 anunknown_fielderror naming it. We never silently ignore a value. - A removed field sent as
nullis ignored. A client that reads a record and sends it back in aPUT, 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 ininfo.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.