Objects
Create, read, update, and delete individual objects. Type names in the path are always the plural REST name — see Qualified names and URLs.
Create
POST /data/{tenant}/{typeName}
Creates a new object. Returns 201 Created with a Location header pointing at the new object's URI and no response body. To retrieve the created object, issue a follow-up Get one against the Location URI. The new object's id is the last segment of that URI.
{
"name": "Acme B.V.",
"registrationNumber": "12345678",
"foundedOn": "2019-03-01",
"legalForm": "bv",
"country": "/data/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/countries/5b1d0c9e-2a47-4f3b-8e61-7c9d2a0f4e18"
}
Request body
The body is a flat JSON object. Its keys are the names of the object's fields and relations, taken from the classes the new object will have:
| Key | Value |
|---|---|
Field name (registrationNumber) | The field's value, encoded per its data type — a string for TEXT, a number for INTEGER/DECIMAL, true/false for BOOLEAN, "2019-03-01" for DATE, an ISO-8601 timestamp with offset for DATETIME, the list key ("bv") for LIST. A field with multiple: true takes an array. |
FILE / IMAGE field | The object returned by the upload — see Files. |
Single-valued relation, by the singular REST name of the relation's type (country) | The URI of the related object, as found in its _links.self.href. |
Multi-valued relation, by the plural REST name of the relation's type (employees) | An array of URIs. Only settable on the class that declares the relation; the other side is read-only. |
Keys that are not a field or relation of the object are silently ignored, so a typo in a field name does not produce an error — the value just isn't stored.
Values the server sets
- Formulas run on every save and overwrite whatever the request sent for that field, unless the formula is marked
onlyWhenMissing. The object'snameis usually such a formula. - Defaults are applied to required fields the request left empty, when the field declares a default.
- Scripts triggered on create run before the object is stored and may change further values — see Scripts.
- The id,
_creationDateand_modificationDateare always set by the server.
Validation
| Status | When |
|---|---|
400 Bad Request | A required field or relation has no value after formulas and defaults (Missing value, with the missing names); a LIST value that is not a key of the field's list; a multi-valued relation or multiple FILE field that is not an array; a file whose metadata doesn't match the upload. |
403 Forbidden | The caller's role or the model's access control does not allow creating this object. |
404 Not Found | typeName is not a known class, or a related object URI does not resolve. |
Create related
POST /data/{tenant}/{referenceTypeName}/{referenceId}/{typeName}
Creates a new object of typeName linked to the existing referenceTypeName/referenceId object. It is an alternative to Create: the URL is the same one that lists the related objects, and a POST to it adds a new object to that list.
For example, in a model where a shareholding dossier links a company to a shareholder (a person or company, through commons.entity), the shareholdings of shareholder X are listed with:
GET /data/{tenant}/shareholders/{X}/shareholdings
A POST to the same URL creates a new shareholding for X:
POST /data/{tenant}/shareholders/{X}/shareholdings
{
"percentage": 0.4,
"company": "/data/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/companies/8d3e2b71-4c0a-4e9f-a1d6-5f2b9c7e0a34"
}
The body has the same shape as for Create, minus the relation the URL already supplies: the shareholder relation is filled in from the path, so the body only carries the fields and the relations that cannot be derived from it — here the company. The result is the same as a POST /data/{tenant}/shareholdings with "shareholder": "/data/{tenant}/…/{X}" in the body; the related-URL form saves the client from building that reference itself, which suits an add button inside a related list.
An optional ?class= query parameter, with a plural REST name, creates a concrete subclass of typeName instead of typeName itself.
Returns 201 Created with a Location header pointing at the new object's URI and no response body. To retrieve the created object, issue a follow-up Get one against the Location URI.
referenceTypeName is the class the relation is declared on, not necessarily the referenced object's own concrete class. A relation's to_type fixes the type the path segment must name, and that can be an ancestor of what the object actually is. Memos filed under a general dossier are created at /dossiers/{id}/memoes, because the relation carrying memos to their parent is declared against commons.dossier — not against whichever concrete dossier subtype the record happens to be. The same rule applies wherever a route takes a referenceTypeName/referenceId pair, including the related list and the create-related capability below.
Get one
GET /data/{tenant}/{typeName}/{id}
Returns a single object as a DataObjectPresentation:
{
"_class": "companies",
"_creationDate": "2026-09-24T09:12:31.482Z",
"_modificationDate": "2026-09-24T09:14:05.107Z",
"name": "Acme B.V.",
"registrationNumber": "12345678",
"foundedOn": "2019-03-01",
"legalForm": "bv",
"_classes": ["companies", "clients"],
"_links": {
"_self": { "href": "/data/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/companies/8d3e2b71-4c0a-4e9f-a1d6-5f2b9c7e0a34" },
"_scripts": { "href": "/scripts/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/companies/8d3e2b71-4c0a-4e9f-a1d6-5f2b9c7e0a34" },
"country": {
"href": "/data/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/countries/5b1d0c9e-2a47-4f3b-8e61-7c9d2a0f4e18",
"name": "Netherlands"
},
"employees": {
"href": "/data/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/companies/8d3e2b71-4c0a-4e9f-a1d6-5f2b9c7e0a34/employees",
"values": [
{ "href": "/data/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/persons/0e7a4c52-9b18-4d3f-b6a0-2c8e1f5d7b93", "name": "Jane Smith" }
]
},
"_metadata": { "href": "/metadata/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/dataObject?classes=clients,companies" },
"_relatedDataObject": { "href": "/metadata/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/relatedDataObject?classes=clients,companies" },
"_form": { "href": "/metadata/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/form?classes=clients,companies" }
}
}
| Property | Meaning |
|---|---|
_class | Plural REST name of the object's BASIC class. |
_creationDate, _modificationDate | Server-maintained timestamps (ISO-8601, UTC). |
| Field names | One property per field that has a value. Empty fields are left out, as are fields marked hidden and every SECRET field. |
_classes | Plural REST names of all classes the object currently has. |
_links._self | The object's own URI. There is no separate id property — the id is the last segment of this href. |
_links._scripts | Where to run the object's scripts. |
_links.{relation} | Single-valued relations, keyed by singular REST name, with the related object's href and name. Left out when the relation is empty. |
_links.{relations} | Multi-valued relations, keyed by plural REST name. href is the related list endpoint; values lists the linked objects, and is left out when there are none. |
_links._metadata, _relatedDataObject, _form | Where to fetch the metadata that describes this combination of classes. |
Properties the platform adds start with _; every other property is a field of the model, whose names never start with _. The same holds in _links: the platform's own links start with _, the others are relations.
The relation keys in _links are the same keys a Create or Patch body uses to set them — send the href values back, not the { href, name } objects.
If id belonged to an object that has since been merged into another, the response is 307 Temporary Redirect to the surviving object.
Structural-eligibility fallback
An object doesn't need to already have typeName among its classes to be returned by this endpoint — it's enough to be structurally eligible for it: nothing about the object's own BASIC class rules typeName out, even if the relation that would actually grant it hasn't been created yet. A legalmanager.company that isn't yet a legalmanager.prospect is still returned by GET /data/{tenant}/prospects/{companyId}, because legalmanager.company could hold prospect once linked through the right relation (see How relations add classes).
This is distinct from a type the object's BASIC class could never hold — GET /data/{tenant}/salesDossiers/{companyId} for that same company still 404s, since nothing links legalmanager.company to legalmanager.salesDossier's inheritance chain. Behavior for an object that already holds the requested type is unchanged.
This is what makes the NAVIGATE relatedClass pattern usable right after the relation is created, without waiting on the class grant to finish first.
Get own
GET /data/{tenant}/{typeName}/own
Returns the object in the database that the caller's account is linked to — typically a person, or a class derived from it. This is how a client finds "me" as a record of the model, for example to show the signed-in person's own details or to pre-fill them as the author of something new.
GET /data/{tenant}/persons/own
The link runs from the account, not from the object: a user profile optionally points at one object, and own follows that pointer. It works for every way of authenticating that resolves to an account:
| Caller | Linked object |
|---|---|
| Signed-in user (access token) | The object the user's profile points at. |
| User-bound API key | The object the key's user profile points at — the key reaches it indirectly, through the account. |
| System API key | None — a system key has no account. |
typeName works as a check as well as a path: the linked object is returned only if it has that class. GET /data/{tenant}/persons/own therefore guarantees that the result is a person; asking for a class the linked object does not have gives no object, just as when there is no linked object at all.
When there is an object, the response is the same DataObjectPresentation as Get one. When there is none — no linked object, the object lacks typeName, or a system key — the response is 404 Not Found.
Patch
PATCH /data/{tenant}/{typeName}/{id}
Partially updates an object. Only the fields present in the body are changed.
Returns 204 No Content with a Location header pointing at the patched object's URI and no response body. To retrieve the updated object, issue a follow-up Get one against the Location URI.
{
"registrationNumber": "87654321",
"legalForm": null,
"employees": [
"/data/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/persons/0e7a4c52-9b18-4d3f-b6a0-2c8e1f5d7b93",
"/data/2f9c6e1a-7b34-4d58-9c21-0a5e8f1b2c3d/persons/a41f7d03-6e2c-4b95-8d17-3c0b9e6f2a58"
]
}
The body uses the same keys and encodings as Create. Per key:
| In the body | Effect |
|---|---|
| Key absent | Left unchanged. |
| Field with a value | Replaced. |
Field with null or "" | Cleared. |
| Single-valued relation with a URI | Replaced. |
Single-valued relation with null or "" | Cleared. |
| Multi-valued relation with an array | The whole list is replaced by the array — to add one object, send the current list plus the new URI; [] removes all links. |
Multi-valued relation with null | Left unchanged. |
After the changes are applied, the object goes through the same steps as on create: formulas are recalculated, update scripts run, and required fields and relations are checked again — so clearing a required field is rejected with 400 Bad Request. The errors are the same as for Create.
There is no optimistic locking: two concurrent patches on the same object are both applied, and where they touch the same key the last one wins.
Delete
DELETE /data/{tenant}/{typeName}/{id}
Deletes a single object. Returns 200 OK with an empty body.
The delete is permanent: there is no soft delete or recycle bin. Other objects that link to the deleted one are handled according to the relation through which they link:
| Relation | Effect on the linking object |
|---|---|
cascadeDelete: true | Deleted as well, through this same pipeline (so its own relations cascade in turn). |
Single-valued and required: true | The delete is refused with 409 Conflict while such objects exist. |
| Anything else | The link is removed and the object is recalculated. |
| Status | When |
|---|---|
403 Forbidden | The caller's role or access control does not allow deleting this object. |
404 Not Found | The object does not exist or is not visible to the caller. |
409 Conflict | A required relation still points at the object. |
Merge
POST /data/{tenant}/{typeName}/{id}/merge
Merges another object of the same BASIC class — the loser — into this object — the winner, identified by typeName/id — then deletes the loser.
{
"loser": "550e8400-e29b-41d4-a716-446655440000",
"patch": {
"email": "winner@example.com"
}
}
loser— the id of the object to merge away, and delete, into the winner. Accepts a bare UUID or a full resource URI (e.g. anhrefcopied straight out of a_linksentry) — only the last 36 characters are read, so a truncated or malformed value is not rejected the way an invalidpatchfield would be.patch— optional, same shape as the body of Patch. Applied to the winner exactly like an ordinary patch: fields and relations not mentioned keep the winner's current value. The server never compares the winner's and loser's field values or detects conflicts — resolving which value wins per field is entirely up to the caller, expressed by what is (and isn't) included inpatch.
Returns 204 No Content with a Location header pointing at the winner's URI and no response body. To see the winner's state after the merge — including any repointed relations — issue a follow-up Get one against the Location URI.
What happens, in order:
patchis applied to the winner as an ordinary patch.- Any classes the loser has that the winner doesn't are added to the winner (a union), regardless of
patch. This keeps working scripts and gateways bound to the loser's types active on the merged object. - Every relation that points at the loser from other objects is repointed to the winner: single-valued relations are overwritten, multi-valued relations are deduplicated (if the winner is already linked, the loser's link is dropped rather than duplicated). The winner's own relations only change through step 1.
- The winner is saved.
- The loser is deleted through the normal delete pipeline: a hard delete, with no tombstone and no pointer left behind from the loser to the winner. By this point nothing should still reference the loser, so its delete constraints normally pass — but see the
409case below.
History-enabled fields are treated like any other field for conflict purposes: the entire history list follows whichever side won that field (winner's or loser's), never interleaved.
Errors:
| Status | When |
|---|---|
400 Bad Request | The loser's BASIC class differs from the winner's. |
404 Not Found | typeName, the winner id, or the loser could not be resolved. |
409 Conflict | Deleting the loser violates a delete constraint that relation repointing didn't clear — the same conflict a direct DELETE on the loser would raise. |
Notes:
- The merge is best-effort, not transactional across the platform's stores, consistent with
save/deleteelsewhere in this API. - Passing the winner's own
idasloseris not guarded against: the object is patched, then immediately deleted by step 5. Never pass the same id for both sides. - There is no server-side conflict UI or SDK helper for this endpoint yet — callers build the
patchpayload themselves.
Capabilities
Ask in advance whether an action would succeed, so a client can offer only the edit button, delete action, or New button that will. Each endpoint answers from the same decision its corresponding write enforces — see Enforcement — rather than a second, independently computed rule, so a capability can never promise what the write would refuse.
Object capabilities
GET /data/{tenant}/{typeName}/{id}/capabilities
{ "mayModify": true, "mayDelete": false }
Capabilities for one existing object. mayModify is the model's WRITE, the same thing Patch asks for; mayDelete is what Delete asks for.
Create capability
GET /data/{tenant}/{typeName}/capabilities
{ "mayCreate": true, "allowedClasses": ["invoices", "creditNotes"] }
Whether the caller could create a typeName with no parent — the question behind the New button above a top-level list. Takes the same optional ?class= query parameter as the POST to ask about a concrete subclass instead of the base class.
allowedClasses names, by plural REST name, the concrete classes that would actually succeed — the same identifier createClasses on a VIEW/RELATED_VIEW layout item uses to fill that button's menu, so a client can intersect the two. A create question is usually asked about a whole family (the '+' above a list of commons.content), and createClasses on its own is purely model-derived — every concrete descendant of the view's class, with no ACL filtering — which is exactly how that menu ends up offering a kind the caller can't actually create. allowedClasses is the filtered half of that pair. It's empty exactly when mayCreate is false, the coarse-role clamp included: a viewer may create nothing, so the clamp empties the list rather than trimming it.
Create-related capability
GET /data/{tenant}/{referenceTypeName}/{referenceId}/{typeName}/capabilities
{ "mayCreate": true, "allowedClasses": ["memoes"] }
Whether the caller could create a typeName related to the given referenceTypeName/referenceId — the question behind the add button inside a related list. Same path variables, and the same optional ?class= subclass parameter, as the POST on that route — see the note on referenceTypeName under Create related. allowedClasses behaves exactly as above, but scoped to this specific parent — the concrete types that would actually succeed when creating under this referenceId, not under the type in general.
This is asked per parent, not per type, because the same access-control definition can grant CREATE on, say, memos filed under a dossier while only granting READ on memos filed under an agreement. A flag keyed on typeName alone, or computed once per view, would be wrong exactly where a model actually distinguishes one parent from another.
Shared behavior
All three endpoints follow the same three rules, each a deliberate choice rather than an accident:
- An unreachable record is a
404, checked first. Ifid(orreferenceId) doesn't resolve to an object the caller can see, the response is the ordinary 404 a nonexistent object would give — the same visibility gate every other route applies — before the route's own logic runs at all. Answering "no" instead would itself confirm the record exists. - A route naming no relation of that type is a
400, not a "no." If nothing in the model connectsreferenceTypeNametotypeNamethe way the path claims, that's a bad request, not a denial — amayCreate: falsethere would read as "the model forbids it" when the truth is the route doesn't describe a real relation at all. - The answer is clamped by the coarse role. A
viewergetsfalsefor every mutating capability (and, for the two create endpoints, an emptyallowedClasses) regardless of what the access model would otherwise grant, exactly asviewerclamps the write itself.