Conventions

The rules that hold everywhere, and the limits worth knowing before you design around them.

Shapes

Ids are strings. resourceId, resourceTypeId and similar come back as strings because some exceed what a JavaScript number holds exactly. Keep them as strings; do not parse them to compare.

Some nested fields are numbers rather than strings — ownerResourceId in a list row, for instance. This is an inconsistency, not a signal. Compare ids as strings after coercing, and it will not bite you.

ownerOrgId on a single-resource read is an internal identifier and may change; use ownerResourceId, which is the owning resource's id.

Timestamps are ISO-8601 in UTC, e.g. 2026-09-14T02:00:12.772Z. Always UTC; there is no local time anywhere in the API.

Fields are camelCase, with two exceptions that predate the rest of the surface and are called out where they appear: POST /v1/resources/find-or-create and POST /v1/aliases/bind take snake_case bodies.

Cursors are opaque. Pass back exactly what you were given.

Errors have one shape — with one exception. Every endpoint answers a refusal as { "error": "<code>", "message": "…" } (errors.md), except the two OAuth endpoints of sign-in (/oauth/token, /oauth/userinfo), which use the standard OAuth 2.0 / OpenID Connect shape { "error", "error_description" } because client libraries expect it (sign-in.md).

Unknown query parameters are ignored, not rejected — a misspelt parameter does nothing and the request answers as if it were absent. Check names against endpoints.md. (A parameter a door knows but cannot apply to a key, such as sort on the resource list, is refused with a code.)

Paging

limit is clamped to 1–100 everywhere. Defaults differ: the resource list defaults to 50, the events read to 30. Always send an explicit limit and the difference stops mattering.

Writing properties

properties merges shallowly. Keys you do not send are left alone. Nested objects are replaced wholesale, not merged — send the complete nested value.

You cannot delete a property key. Sending {"colour": null} stores a null; it does not remove colour. There is currently no way through this API to make a key not exist again.

Moves that record an event

Some status moves record an event when they complete — converting a bid records bid.converted, for instance. The advancement read tells you which: each move's records names the event, or is null (endpoints). Do not post that event yourself after the move; it is already recorded, and a second one is refused.

Properties you cannot write

Two kinds of property refuse your writes, and both refuse the entire request rather than applying part of it. A patch either lands completely or not at all.

Confirmed properties — a person has vouched for the value. 409 confirmed_key_locked, with keys naming which. A person must re-confirm before it can change; your key cannot overturn somebody's assertion. Read confirmedKeys on a resource, or add include=properties to a list, to see which are locked before you try.

Derived properties — computed by a formula from other data. 400 derive_target_write_blocked, with keys naming them and where each is computed. A manual value would be overwritten the next time the formula runs, so the write is refused instead of silently lost. derivedKeys on a resource read tells you which they are.

If one property is both, you get the derived refusal.

Names you cannot read

A response never tells you the name of something your key cannot read. Where a field would name it, you get a neutral word instead, and the field keeps its type — a name is still a string:

What the field names The word you get
a person, an organization, an actor, a counterparty someone
any other resource a resource
a resource type a type
something above the resource (a parent, a setting's source) an enclosing item
something inside the resource an item inside
a setting's value a setting

Ids are not secret. The id beside a hidden name is still there, so you can tell two hidden things apart, and a row that names something you cannot read carries readable: false. You can always rely on that flag; never compare a name against one of the words above.

What this means in practice:

Reading efficiently

List rows omit properties by default. Add include=properties and each row gains properties and confirmedKeys — one request instead of one per row. Do that rather than listing ids and fetching each one; the difference is large over a few hundred rows.

Reference data (types, statuses, lifecycles, units) changes rarely. Fetch it at startup and keep it.


What you can't do yet

Said plainly, so you can design around it rather than discover it.

Writes are not idempotent. There is no Idempotency-Key. A retried PATCH writes its events a second time, and a request that timed out may have succeeded. Read the resource back before resending a write. The one exception is POST /v1/resources/find-or-create, which is idempotent by your own identifier — for anything you create, prefer it. A request that creates a resource together with what it holds replays the same way: each resource in it is found again by its own identifier, and nothing is created, put inside or connected twice.

You cannot delete a property key. See above.

Some surfaces are not open to keys. A resource's canvas placement, its explanation (/why), the compatibility candidates and every authoring surface (types, lifecycles, compatibility markings, retyping a connection) require a signed-in person; 401 or 403 there is the current design, not a misconfiguration. A key reaches a resource's members, containers, events, advancement, requirements and ports (with legal targets), the cross-type search, and it can enrol, exit and move members and make or remove an attachment — see endpoints.md for every door.

There is no bulk read of compatibility rules. A type's compatibility pairings (GET /v1/resource-types/:id/compat-pairings, and one pairing at …/compat-pairings/:partnerTypeId) are served to signed-in people only; a key gets 403 compat_authoring_session_only there. A key learns what fits one field at a time: GET /v1/resources/:id/ports/:portId/legal-targets gives each candidate's verdict and the reasons — the same answer the attachment would get.

A key cannot link a revision to the bid it replaces. A revision is a new bid; the link from it to the one it replaces (derives_from) has no ports, so a key's POST /v1/resource-interactions is refused 400 ports_required (walkthrough).

You cannot manage your own key. Creating, rescoping and revoking are administrator actions through the web application. Your key cannot even read the list of keys — only GET /v1/api-keys/self.

Keys do not expire. A key stays valid until somebody revokes it. Plan your own rotation — a connected app's machine key is rotated by your organization's administrator from the app's page (a new name, the old key revoked).

Authentication failures come back in two different shapes. On most endpoints a bad or revoked key returns { statusCode, error, message } with a phrase in error, not a code. Treat any 401 as "check your key" — see errors.md.

A few doors return sentences rather than codes for some of their own validation — attestations and status changes among them. errors.md lists every one. Match on the HTTP status there.

There is no webhook or push for keys. To notice a change you poll a resource's events with after and the cursor from your last poll (events.md). ReSync's own web app and device credentials receive live updates over a stream; that stream is not open to integration keys.

One write creates a resource and what it holds; everything else is one resource per request. POST /v1/resources/find-or-create takes the resource's fields and the resources inside it, to any depth (up to 250 in one request), and writes them all or none (endpoints) — a whole bid is one request. Changing what already exists (a PATCH, a status move, replacing a field) is still one request per change (walkthrough).