Metadata
Every core object carries a metadata bag: a key-value map you own, that Synex stores and returns and never interprets. It exists so an integrator can staple its own identifiers onto a Synex object — a purchase order number, an ERP row id, a reconciliation key — without spending a property definition on data that is not part of the product's schema.
A passport with metadata
{
"object": "passport",
"id": "pass_3e9a1c5b7d2f4083",
"values": { "property_capacity": { "value": 78.4 } },
"metadata": { "po_number": "PO-7712", "erp_row": "44190" }
}
Shape
String keys, string values, one level deep. No numbers, no booleans, no nested objects or arrays. A caller that wants a number encodes it as text.
That narrowness is deliberate rather than an oversight. The bag is round-tripped verbatim through storage, event payloads and webhook deliveries, and a field whose JSON type varies with its contents is a field every generated client has to special-case. One type, always.
| Limit | Value |
|---|---|
| Keys per object | 50 |
| Key length | 40 characters |
| Value length | 500 characters |
Every violation is 400 with code metadata_invalid. param names the whole field (metadata) for a whole-bag problem — too many keys, not an object — and the specific entry (metadata.po_number) for a problem with one key or value, so you can point at the offender without parsing the message.
The limits are checked against the merged result of a write, not against the patch you sent. A patch cannot grow an object past 50 keys one key at a time.
Reading
metadata is on every read of every object that has it, and it is always an object: {} when nothing has been set, never [] and never a missing key. The field's presence does not depend on its contents, so you can read object.metadata unconditionally.
Writing
Creates take an optional metadata. Updates take one too, and merge it per key rather than replacing the bag:
| You send | Result |
|---|---|
"key": "value" | The key is set to that value |
"key": null | The key is deleted |
| Key not mentioned | Left exactly as it was |
{} or null as the whole field | No change |
| Field absent entirely | No change |
Merge-not-replace is what makes the bag safe to share. Two unrelated systems can each own their own keys on the same passport without either one clobbering the other's, which a replace semantic would make impossible without a read-modify-write race between them.
Merging
# Set two keys, leave everything else alone.
curl -X PATCH https://api.synexcloud.com/v1/passports/pass_3e9a1c5b7d2f4083 \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"metadata": {"po_number": "PO-7712", "erp_row": "44190"}}'
# Delete one key. po_number survives.
curl -X PATCH https://api.synexcloud.com/v1/passports/pass_3e9a1c5b7d2f4083 \
-H "Authorization: Bearer $SYNEX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"metadata": {"erp_row": null}}'
On a create there is nothing to merge into, so a null value simply means "no such key" — a create and an update never disagree about what the same payload means.
Where it is available
| Object | Set on create | Update |
|---|---|---|
template | POST /v1/templates | PATCH /v1/templates/{template} |
ontology | POST /v1/ontologies | PATCH /v1/ontologies/{ontology} |
passport | POST /v1/ontologies/{ontology}/passports | PATCH /v1/passports/{passport} |
composition | POST /v1/ontologies/{ontology}/composition | PATCH /v1/ontologies/{ontology}/composition |
import | POST /v1/imports | — imports are immutable once started |
file | POST /v1/files (multipart) | — files are immutable once uploaded |
webhook_endpoint | POST /v1/webhook_endpoints | PATCH /v1/webhook_endpoints/{webhook_endpoint} |
api_key | POST /v1/api_keys | PATCH /v1/api_keys/{api_key} |
Three of those rows carry a wrinkle worth knowing before you build on them.
PATCH /v1/ontologies/{ontology} accepts nothing but metadata. An ontology's name, description and level structure come from its template and change only through the draft/version lifecycle, where every edit is validated against the passports already issued under the old schema. This endpoint is not a second, unversioned way in. metadata must be present in the body — an empty body is 400 parameter_invalid naming metadata — so send {} if you mean a deliberate no-op.
Template metadata can be patched on a complete template, which is otherwise immutable. Completion freezes the schema, not your bookkeeping, so a metadata-only patch is accepted where a structure patch would be 409 template_not_draft. Send any other field alongside metadata and the whole request takes the ordinary update path, immutability check included.
On POST /v1/files the bag is a JSON-encoded string, because a multipart body cannot carry a nested object. Send the form field metadata with the text {"source_system":"erp-prod"}; it is parsed and then validated identically. Text that is not valid JSON, or that decodes to something other than an object, is 400 metadata_invalid.
Known limitation: GET /v1/imports always reports {}
The import list reports metadata: {} on every row, no matter what the import actually carries. GET /v1/imports/{import} is truthful.
This is not a subtlety of the merge rules; it is a gap. List rows are served from a SQL index built for progress polling, and that index has no metadata column — the bag lives on the storage item only a detail read touches. The field is present on the list shape so the object's shape does not change between the two reads, but on that shape it is not a read of anything.
Do not reconcile against the list. If you are matching imports to your own records by a metadata key, fetch each import by id, or key on the import_ id you received from POST /v1/imports in the first place.
What it is not
- Not searchable. No endpoint filters or sorts by a metadata key. If you need to look an object up by your own identifier, keep your own index — or use
friendly_idon a passport, which is addressable. - Not private. It is returned to any key that can read the object, and it travels in webhook payloads. Do not put secrets, credentials or personal data in it.
- Not a property. Values in
metadataare never validated against the ontology, never rendered on a public passport, and never versioned with the schema. Data that belongs to the product itself belongs invalues.