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:
- Connections and provenance. An
edgesrow or aderivedFromrow to a resource you cannot read stays in the list withreadable: false; aderivativesrow you cannot read is left out. - Computed properties. A
derivedKeysentry whose formula lives on a parent you cannot read names itan enclosing itemand keepsparentResourceId, withreadable: false. - Types. A resource whose type you cannot read reports
resourceTypeName: "a type"and no schema. - Requirements. A requirement whose condition names something you cannot read describes it with the words above.
- Refusal messages name only what you can read. Messages may be reworded at any time
(errors); key on
error, never on the message.
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).