Skip to main content

Entity curation API

The entity curation API lets applications create and curate catalog entities, maintain shared vocabulary values such as tags and domains, and read the active entity catalog.

All endpoints require authentication and return the standard DataDios response envelope:

{
"STATUS": "SUCCESS",
"MESSAGE": "",
"DATA": {}
}

A refused request has STATUS: "FAIL" and a machine-readable code in DATA.reason. Applications should branch on DATA.reason, never on the text in MESSAGE.

Entity references use the public shape {guid, name}. Responses do not expose internal identifiers, relationship keys, or search fields. Attributes are addressed and returned by name within their owning entity type.

Write entities​

Method and pathPurposeSuccess status
POST /entitiesCreate an entity within an existing entity type201 when created; 200 when the name already exists
PATCH /entities/{guid}/attributesSet or clear attribute values on an entity200
POST /entities/vocabularyCreate a vocabulary value, such as a tag, domain, or environment201 when created; 200 when the value already exists
PATCH /entities/{guid}/nameRename a user-created entity or vocabulary value200
DELETE /entities/vocabulary/{guid}Remove a vocabulary value from every entity and delete it202
DELETE /entities/attributes/{type_guid}/{name}Withdraw an attribute from an entity type202
POST /entities/curation-jobsApply attribute values across many entities202

Create an entity​

{
"entity_type": "<entity type guid>",
"name": "Customer Orders",
"attributes": {
"domain": ["Finance"],
"tags": ["PII", "GDPR"]
}
}

POST /entities is create-or-get, not create-or-update. Names are compared after trimming spaces and ignoring letter case. If the name already exists, the API returns that entity with "created": false and writes nothing.

One case does not return the existing entity: if the name you supplied is one that another value was renamed onto, the create is refused with 409 name_already_in_use. A value keeps its original identity through a rename, so a value created as "PII" and renamed to "Sensitive" is still identified as "PII" — creating "Sensitive" would therefore mint a second, separate value displaying the same text in the same type. The refusal is what prevents that. Use the existing value instead of creating a new one.

The reverse also follows from the same rule: creating "PII" after that rename returns the same value with "created": false, and its name in the response reads "Sensitive" — the name you asked for identified it, and the response reports what it is called now. Match on the returned guid, not on the name you sent.

If a create request includes attributes but finds an existing entity, the request is refused with 409 entity_already_exists. Use PATCH /entities/{guid}/attributes to update an entity that may already exist.

From the DataDios release after v1.13.0, entities of a type DataDios manages, such as dashboard, can't be created through this API. The request is refused with 409 type_instances_managed, because DataDios adds those entries itself (see Dashboards in the catalog). You can still set their attributes. When you set an attribute that refers to entities by name, a name of a managed type must be one that already exists: naming a dashboard that doesn't exist is refused with the same 409 type_instances_managed, rather than creating it.

Set or clear attributes​

{
"attributes": {
"tags": ["PII"],
"cost_center": []
}
}

An empty list clears that attribute. An attribute omitted from the request is left unchanged. The operation is all-or-nothing: if one value is refused, nothing in the request is written.

Create or rename a vocabulary value​

Create a value:

{
"entity_type": "<tag type guid>",
"name": "PII"
}

Rename a value:

{
"name": "Sensitive PII"
}

PATCH /entities/{guid}/name renames any entity you created, not only a vocabulary value — the same call renames a tag value and an ordinary entity. Values discovered from a source and built-in values keep their source-owned names and cannot be renamed. Two values in one type cannot be given the same name: a rename onto a name another value already displays is refused with 409 name_already_in_use.

Creating a value is matched loosely, renaming is matched exactly. Creating "pii" where "PII" already exists returns the existing value rather than a second one, because a new value is matched on its name with spaces trimmed and letter case ignored. A rename compares the stored name exactly, so it is possible to end up with "PII" and "pii" side by side in one type. Treat a value's name as a label for people and its guid as its identity — do not use letter case to tell two values apart.

A rename changes only the display name. Existing assignments, filters, and counts continue to refer to the same value.

A rename cannot be undone by the system and is not recorded in an audit trail. The previous name is returned in the rename response and is not stored anywhere afterwards, so keep it if you need it. Renaming also reorders the value within name-sorted listings: if you are paging through a list while a rename happens, the renamed row can appear twice or not at all on the pages you have left to fetch. Start the listing again if you need an exact set.

Delete a vocabulary value or withdraw an attribute​

Both delete endpoints accept an optional confirmation body:

{
"confirm": true
}

When the operation affects more than 10,000 entities, omitting confirmation returns 409 confirmation_required. Only the JSON boolean true is accepted; the string "true" is not confirmation. Operations affecting 10,000 or fewer entities do not require confirmation.

Withdrawing an attribute is irreversible and has no retention window. The attribute disappears from reads and writes when the request is accepted, while stored values are removed in the background. Attributes cannot be renamed or have their value type changed. The built-in domain, env, tags, classification, and groups attributes cannot be withdrawn.

Curate many entities​

{
"entity_guids": ["<entity guid>", "<entity guid>"],
"entity_type": "<entity type guid>",
"set_values": {
"domain": ["Finance"]
},
"delta_values": {
"tags": {
"add": ["PII"],
"remove": ["Legacy"]
}
}
}

set_values supplies the complete desired list for an attribute. delta_values adds and removes individual values. An attribute can appear in only one of these request sections.

A bulk request accepts at most 25,000 entities and 10 attributes. If entity_type is supplied, attribute names are resolved against that type and the built-in attributes. If it is omitted, only built-in attributes are available.

A guid that no longer resolves is reported under DATA.skipped.entity_not_found, with a count and a sample of up to 50 guids. It appears in the submit response and in the background job's status, so losing the submit response does not lose the report. Entities skipped later because an attribute does not apply or the entity was retired are added to the same place as the job runs. If no guid resolves, the request is refused with 400 no_entities_resolved.

Track background operations​

The two delete endpoints and the bulk curation endpoint return 202 with DATA.request_id. Poll the existing POST /async_status_check endpoint with:

{
"request_id": "<request id>",
"job_type": "entity_bulk_curate"
}

Use the job type that matches the submitted operation:

Operationjob_type
Delete a vocabulary valueentity_vocabulary_delete
Withdraw an attributeentity_attribute_withdraw
Curate many entitiesentity_bulk_curate

Large removals are processed in batches of 5,000. An entity updated at the same time can briefly remain filterable by the removed value; a background repair corrects that temporary state.

Read entities​

Method and pathPurposeSuccess status
POST /entities/filterFilter one entity type by attribute values200
POST /entities/catalogOne name-sorted page spanning several entity types200
POST /entities/lookupRead the rows for a list of guids you already hold200
POST /entities/infoRead schema drift and curated attributes for entities by guid200
POST /entities/facetsList values for one attribute or count named attribute/value pairs200
GET /entities?type={guid}Browse active entities of one type200
GET /entities/{guid}Read an active entity profile200
GET /entities/{guid}/childrenList direct active children200
GET /entities/resolve/{guid}Resolve a live guid or remembered rename200; unknown guid returns 404

Read endpoints return active entities only, with no request option to include missing or retired entities. POST /entities/info is the exception: it answers for missing and retired entities too. Review and audit views are separate from this API.

Filter entities​

{
"entity_type": "<entity type guid>",
"terms": [
{
"attribute": "domain",
"values": ["Finance"]
},
{
"attribute": "tags",
"values": ["PII", "GDPR"],
"any": true
}
],
"page_size": 50
}

Terms combine with AND. Values within a term also combine with AND unless that term sets "any": true, which changes only that term to OR. Every filter is scoped to entity_type because the same attribute name can mean different things on different types.

Catalog entities​

POST /entities/catalog is one name-sorted page spanning several entity types — the catalog screen's table. It is not a widened filter: it has its own request shape and its own cursor kind.

{
"entity_types": ["<entity type guid>", "<entity type guid>"],
"terms": [{ "attribute": "domain", "values": ["Finance"] }],
"name_prefix": "orders",
"page_size": 50
}

entity_types selects which types the page spans; an empty list means every type. terms filters the same way POST /entities/filter does. name_prefix matches case-insensitively. All fields are optional.

Views, procedures and functions are Table rows

From the DataDios release after v1.13.0, four entity types no longer exist: View, Materialized view, Procedure and Function. Objects of those kinds are now catalogued as Table, and each row carries its own kind in sub_type — view, materialized view, procedure or function. A plain table's sub_type is null. The same arrangement already applied to files, which share one file type and carry their extension in sub_type.

What changes for you:

  • Listing entity types returns four fewer. A saved request naming one of their guids is answered 404 entity_type_not_found.
  • Selecting Table in the catalog now returns views and routines alongside tables. Use sub_type to tell them apart, or to filter client-side.
  • An entity keeps its guid and its name. Only its type and sub_type change.
  • Attributes declared on Table are untouched, and so are the built-in domain, env, tags, classification and groups attributes, which every type shares.
Read your View, Procedure and Function attribute values out before you upgrade

Every attribute you declared on View, Materialized view, Procedure or Function is deleted by the upgrade, together with every value recorded against it and every label built from those values. An attribute belongs to one entity type, so an attribute declared on View has no owner once the type is gone. This is irreversible and has no retention window.

Read the values out before you move to the release after v1.13.0: POST /entities/catalog over each of the four types returns their rows with the curated values. Afterwards you can declare the same attribute on Table and record the values again from what you saved.

The response carries two fields no other read returns, alongside the usual entities and next_cursor:

{
"STATUS": "SUCCESS",
"MESSAGE": "",
"DATA": {
"entities": [{ "guid": "<entity guid>", "name": "orders" }],
"count": 1250,
"count_capped": true,
"matched_definitions": [
{ "guid": "<attribute def guid>", "name": "domain" }
]
}
}

count is the total matching the request, capped at a fixed ceiling; count_capped tells you whether the number is exact or a floor. matched_definitions lists every attribute definition a filter term's attribute name matched — useful because the same attribute name can belong to more than one type when the request spans several types, and a caller seeing only rows cannot tell which definition it filtered on.

Sending more than 10,000 entity type guids in one request is refused with 400 invalid_request ("Malformed request body"), and an invalid name_prefix with 400 invalid_name_prefix.

A request with no type selection, no filter term and no name_prefix is the catalog's landing state: it reads every type the schema has, and is answered normally.

Look up entities by guid​

POST /entities/lookup is the read to use when you already hold a set of entity guids and want their current rows in one call — for example, guids you stored earlier or received from another read. Send up to 500 guids in one request:

{ "guids": ["<guid>", "<guid>"] }

It answers with the same rows every other list read returns, and it is not paged: your guid list is the page, so no cursor comes back.

{
"STATUS": "SUCCESS",
"MESSAGE": "",
"DATA": {
"entities": [{ "guid": "<entity guid>", "name": "orders" }],
"forwarded": {}
}
}

Three behaviours are worth knowing before you build on it:

  • A guid that names no active entity is left out of the response, not treated as an error. There are several ordinary reasons for this, so treat an absent guid as normal rather than building a list of cases to guard against: cataloguing runs in the background, so an object can be visible elsewhere in the platform before its entity exists; the entity may since have been retired; and a data source shared with you is catalogued under its owner's account, not yours. Read the response by guid and show a row with no attributes when a guid is absent.
  • Order is not preserved and repeated guids collapse. Match rows to your list by guid rather than by position.
  • A renamed entity comes back under its new guid. If an entity has been renamed since you stored its guid, the lookup follows the rename and returns the current row — under the new guid, not the one you sent. The forwarded map in the response tells you which guid answered which request ({"<guid you sent>": "<guid that answered>"}), and is present even when empty. Check it for any guid you do not find among the returned rows.

Sending more than 500 guids is refused with 400 too_many_guids. An empty list is refused with 400 invalid_request, the same code any malformed body gets — the list is a required field, not a value checked once the request is otherwise valid.

Read entity information​

POST /entities/info answers "what do you know about these entities?" for guids you already hold. It returns one result per guid: whether the guid still names an entity, and one entry per information type you asked for in include. There are two types:

  • schema_drift — the schema changes the drift job recorded for that entity within the last days.
  • curated_attributes — every attribute value the entity carries now, built-in and custom.

It has two modes:

  • summary, the default, takes up to 100 guids and returns one state and a summary for each. items stays empty. One call behind a table page; send more guids in batches.
  • detail takes exactly one guid and one information type and returns its items a page at a time. The drill-down behind one row.
{
"guids": ["<guid>", "<guid>"],
"include": ["schema_drift", "curated_attributes"],
"mode": "summary",
"days": 30
}
{
"guids": ["<guid>"],
"include": ["schema_drift"],
"mode": "detail",
"days": 30,
"page_size": 50
}
FieldRule
guidsRequired. 1 to 100 distinct guids; repeats collapse. The format is not checked — a guid that names nothing comes back not_found, so one bad guid does not fail the batch.
includeRequired. The information types to return: schema_drift, curated_attributes.
modesummary (default) or detail.
daysWhole number, 1 to 365, default 30. How far back schema_drift reads. curated_attributes ignores it.
page_sizedetail only. 1 to 100, default 50. A drift page can hold fewer items: it also stops once its diffs pass about 4 MB. Follow next_cursor; do not count items.
X-Pagination-Cursor headerdetail only. Send the previous response's next_cursor to get the next page. A cursor works only with the same guid, include and days.

The response keys results by the guid you sent:

{
"STATUS": "SUCCESS",
"MESSAGE": "",
"DATA": {
"results": {
"<guid>": {
"state": "resolved",
"entity": {
"guid": "<guid>",
"name": "orders",
"entity_type": "table"
},
"info": {
"schema_drift": {
"state": "has_data",
"reason": null,
"summary": {
"highest_impact": "HIGH",
"change_count": 20,
"latest_change_at": "2026-10-01T09:30:00"
},
"items": []
},
"curated_attributes": {
"state": "has_data",
"reason": null,
"summary": {
"value_count": 2,
"truncated": false,
"attributes": [
{
"attribute_name": "owner",
"ordinal": 0,
"value": "finance-team"
},
{
"attribute_name": "tags",
"ordinal": 0,
"value": "PII",
"reference": { "guid": "<tag guid>", "name": "PII" }
}
]
},
"items": []
}
}
},
"<old guid>": {
"state": "unresolved",
"reason": "renamed",
"new_guid": "<guid>"
},
"<unknown guid>": { "state": "unresolved", "reason": "not_found" }
}
}
}

In detail mode items is filled and DATA.next_cursor appears beside results while another page exists. It is absent on the last page. Drift items come newest first:

"items": [
{
"version": 12,
"sync_time": "2026-10-01T09:30:00",
"inherited": false,
"object_id": "sales:public:orders",
"version_summary": { "...": "..." },
"object_details": { "...": "..." }
}
]

A data source's items are per-sync summaries — version, sync_time, inherited and version_summary, with no object_id or object_details.

A column's change is recorded with its table, so a column's item is cut from the table's record:

{
"version": 12,
"sync_time": "2026-10-01T09:30:00",
"inherited": false,
"object_id": "sales:public:orders",
"version_summary": null,
"object_details": {
"diff_type": "update",
"meta_diff": {
"attributes": {
"AMOUNT": {
"is_diff": true,
"property_diff": { "DATA_TYPE": [true, "BIGINT", "INTEGER"] }
}
}
}
}
}
  • object_id is the table's, and object_details holds this column's entry only, never its siblings'.
  • Each property is [changed, new value, old value]. An added column has empty old values and a dropped column has empty new values.
  • diff_type says what happened to the table: update, insert or delete.
  • inherited is true when the table itself was added or dropped, so the column is listed without a change of its own. It is false everywhere else.
  • version_summary is null, because the table's summary describes the whole table.

Reading a column through its table's records is limited to 10 seconds. If that read fails, the columns in the request return error for schema_drift and every other entity keeps its answer. A NUL character in a table's record comes back as U+FFFD in a column's item.

Some connectors record drift for the column itself. Those items come back as stored, like a table's: the column's own object_id, its version_summary and its full object_details.

Guid states. Every guid gets a state, and unresolved carries a reason:

statereasonMeaningentity and info returned?
resolvednoneAn active entityYes
unresolvedmissingNot found by the last sync; it may come backYes
unresolvedretiredGoneYes
unresolvedrenamedRenamed. new_guid is the current guid; ask again with itNo
unresolvednot_foundNo record: it never existed, its data source was deleted, or it is not catalogued yetNo

Information states. Every information type answers with the same four states, and each type carries its own, so one type being unavailable does not affect the others in the same response:

stateMeaning
has_dataThe type holds something for this entity. summary is filled.
no_dataThe type holds nothing for this entity. summary is null.
unavailableThe type cannot answer for this entity. reason says why.
errorThis type failed; other types are unaffected.

For schema_drift:

statereasonMeaning
has_datanullAt least one drift record within days.
no_datanullNo drift record within days.
unavailabledrift_not_supportedThe connector has no drift support, or the entity belongs to no data source.

highest_impact is HIGH, MEDIUM, LOW, or null when no record carried an impact score. For a column it is usually null: the impact is scored for the table as a whole, so it is not reported on the column. A column's change_count adds one for each sync whose table record shows a change to the column, including the sync that added or dropped the table.

For curated_attributes, has_data means the entity carries at least one value and no_data means it carries none. It is never unavailable.

  • Each attribute is one value: attribute_name, ordinal (its position within a multi-valued attribute) and value. A value that is another entity, such as a tag or a domain, also carries reference with that entity's guid and name.
  • In summary mode the values are in summary.attributes. An entity with more than 200 values, or whose values weigh more than 40 KiB (attribute names, text, JSON and number values, and the names of referenced entities), returns truncated: true and an empty attributes; value_count still gives the total. Read that entity in detail mode.
  • A number value is a JSON number when that is exact: a whole number up to 2^53, or any value a float holds without loss. Any other number is sent as decimal text: plain decimal text up to 40 characters, sign and decimal point included, such as "12345678901234567890123456789012345678", and exponent form past that, such as "1E+5000". A number inside a JSON value is sent as stored.
  • A date value is ISO-8601 text in UTC with no offset, such as "2026-10-01T10:00:00". Drift's sync_time and latest_change_at have the same form.
  • In detail mode the values are in items, ordered by attribute name, and summary holds only value_count.
  • A withdrawn attribute's values are left out.
  • A value that points at an entity no longer active has a null value and reference.
  • Missing entities, and entities retired because they were dropped, still return their attributes. An entity retired by a rename returns none: its attributes moved to the entity it was renamed to.

A detail request for attributes, and one of the items it returns:

{
"guids": ["<guid>"],
"include": ["curated_attributes"],
"mode": "detail",
"page_size": 50
}
"items": [
{
"attribute_name": "tags",
"ordinal": 0,
"value": "PII",
"reference": { "guid": "<tag guid>", "name": "PII" }
}
]

Seven drift behaviours to know before you build on it:

  • no_data for schema_drift is not proof that nothing changed. A record exists only for a sync that found a change, and the same state covers a data source with drift turned off (switch it on from the Drift tab of the data source dialog).
  • A database or schema reads has_data whenever one of its tables changed. Its change_count can be 0 in that case.
  • Drift follows a rename. The new guid returns the history recorded under the old name.
  • Missing and retired entities still return their drift, including the record of the drop.
  • Drift recorded before it was linked to entities is linked by the upgrade. A record whose object is no longer listed in the data source stays hidden.
  • The first drift sync of a data source records every object, so everything reads has_data for that window.
  • Only connectors that support drift answer. The rest return unavailable.

If every information type you asked for fails, the whole request returns 500 internal_error instead of an error state. So a request for one type never returns error.

More than 100 distinct guids is refused with 400 too_many_guids, and a cursor that is malformed or was issued for a different guid, include or days with 400 invalid_cursor. Everything else is 400 invalid_request: an unknown include, detail with more than one guid or more than one include, a page_size or a cursor in summary mode, days outside 1-365, and a page_size outside 1-100.

Read facets​

List the values for one attribute:

{
"entity_type": "<entity type guid>",
"attribute": "tags",
"page_size": 50
}

Count a named set of attribute/value pairs:

{
"entity_type": "<entity type guid>",
"pairs": [
{
"attribute": "domain",
"value": "Finance"
},
{
"attribute": "tags",
"value": "PII"
}
]
}

A facet request must contain exactly one of attribute or pairs. The pair-count response echoes the supplied attribute and value beside each count.

Stored facet counts can temporarily be higher than the number of visible filter results. Counts include entities in the temporary missing state, while reads return active entities only. A zero-count facet value is still returned.

Browse and paginate​

Browse one entity type with GET /entities?type={guid}. Filter, browse, children, and facet-value responses use cursor pagination:

{
"STATUS": "SUCCESS",
"MESSAGE": "",
"DATA": {
"entities": [
{
"guid": "<entity guid>",
"name": "orders"
}
],
"next_cursor": "<opaque cursor>"
}
}

next_cursor is omitted on the final page. Store it unchanged, then send it only in the X-Pagination-Cursor request header on the next call:

X-Pagination-Cursor: <opaque cursor from the previous page>

Do not put cursors in a JSON body or query string; those forms are refused. Do not decode, construct, modify, share between users, or reuse a cursor on another endpoint. A malformed, truncated, modified, endpoint-foreign, or cross-user cursor returns 400 invalid_cursor.

The facet pair-count shape is not paginated. Supplying either page_size or X-Pagination-Cursor with pairs returns 400 invalid_request.

Page size must be between 1 and 500. The API does not return a total result count.

Read a profile​

GET /entities/{guid} returns the entity, its type and parent, and values grouped by attribute name:

{
"STATUS": "SUCCESS",
"MESSAGE": "",
"DATA": {
"entity": {
"guid": "<entity guid>",
"name": "orders"
},
"type": {
"guid": "<entity type guid>",
"name": "table"
},
"parent": {
"guid": "<parent guid>",
"name": "public"
},
"attributes": {
"domain": [
{
"guid": "<domain guid>",
"name": "Finance"
}
],
"tags": [
{
"guid": "<tag guid>",
"name": "PII"
}
],
"score": [7]
}
}
}

type and parent are each null when that related row is retired; parent is also null for a root entity. A retired referenced value is returned as null rather than exposing stale identity information.

Read direct children​

GET /entities/{guid}/children returns only the entity's direct active children. It does not return ancestors, breadcrumbs, a recursive subtree, missing entities, or retired entities.

Resolve a guid​

GET /entities/resolve/{guid} has two successful outcomes.

A live guid, or an old guid that forwards to a live renamed entity, returns:

{
"outcome": "entity",
"entity": {
"guid": "<entity guid>",
"name": "orders"
}
}

When a rename is remembered but its successor is no longer active, the response returns:

{
"outcome": "alias_memory",
"alias": {
"old_name": "orders_old",
"successor_guid": "<successor guid or null>",
"method": "<rename method>",
"established_at": "<timestamp>"
}
}

successor_guid remains available while the successor row exists, including when it is retired, and is null only after that row is gone. An unknown guid returns 404 entity_not_found.

Remembered renames are resolved in one request; callers do not follow alias chains themselves.

Request rules and limits​

Unrecognized fields and non-object JSON bodies are refused with 400 invalid_request; they are not silently ignored. Other limits are also refusals, never silent truncation:

LimitMaximum
Filter terms50
Values in one filter term50
Named facet pairs50
Guids in one lookup500
Guids in one info request500 (distinct)
Items in one info detail page100
Bulk entities25,000
Attributes in one bulk request10
Text value length1,000 characters
JSON value size16 KB
Number precision38 significant digits
Request body size8 MB

Curated values cannot contain a NUL character.

Refusal codes​

These lists cover the codes reachable through the endpoints on this page, not every refusal code the entity API has — a handful more belong to endpoints (attribute definitions, entity deletion, the review queue) outside this guide's scope.

400: Fix the request​

invalid_request, invalid_cursor, invalid_page_size, invalid_filter_shape, attribute_not_filterable, type_mismatch, not_in_allowed_values, cardinality_violation, unknown_value_type, unknown_def, ref_type_mismatch, empty_value, too_many_values, too_many_defs, def_in_both_request_shapes, no_entities_resolved, value_too_long, value_too_large, number_too_precise, nul_byte_in_value, too_many_allowed_values, too_many_guids, invalid_type_selection, invalid_name_prefix.

404: A named object does not exist​

entity_not_found, entity_type_not_found, unknown_entity_type, attribute_not_found, value_not_found, parent_not_found, def_not_found, referent_not_found.

409: The target's state or references refuse the operation​

entity_already_exists, confirmation_required, already_withdrawn, system_def_not_withdrawable, not_a_vocabulary_value, attribute_withdrawn, entity_retired, referent_retired, name_already_in_use, rename_not_allowed, type_instances_managed, type_has_live_instances, type_owns_attribute_defs, referenced_as_allowed_ref_type, referenced_as_default_value.

The last four codes mean that something still points at the target being removed.

413: The request body is too large​

request_too_large. The body exceeded 8 MB and was refused before it was read, so nothing about its contents was checked. Send fewer entities or fewer values per request. A bulk request that stays inside the per-value limits above can still exceed this ceiling, because those limits apply per value and this one applies to the whole body.