Events
Every change in ReSync appends an event. Nothing is overwritten, so a resource's history is complete and permanent — including the changes you make.
Reading history
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/v1/resources/14211/events?limit=3"
Response: { items: [...], nextCursor: string | null, afterCursor: string }, newest first. Read scope for the
resource's type is required, and the resource must belong to your organization:
From events-outside-grant.json — status 403:
{
"error": "subject_outside_grant"
}
| Parameter | Type | Default | Notes |
|---|---|---|---|
limit |
string | 30 |
Clamped to 1–100. |
cursor |
string | — | From a previous nextCursor. |
filter |
string | all |
See below. |
after |
string | — | Poll for what changed since you last looked — see Polling for changes. |
Filters
all— the default. Messages and the curated set of notable changes.changes— the full change stream, including low-level entries the default view leaves out. Use this when you are reconciling data rather than showing a human what happened.messages— conversation only.quiet— configuration entries only: a lifecycle, a consequence rule, a view's settings or a status's attention changed. They are recorded on the thing configured — and only there: a configuration change never appears on the history of a resource that merely uses or contains the thing configured. They are left out ofallandchanges; ask for them here.
Anything else in filter is treated as all rather than rejected.
Polling for changes
To keep up with a resource, read its history once, keep the afterCursor from that response, and from then on
ask only for what came after it:
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/v1/resources/14211/events?filter=changes&after=$AFTER"
With after, events come oldest first, and nextCursor is always present — it is where your next poll
starts, even when nothing new has happened (then it is the cursor you sent). Store it and send it next time.
after=start begins at the very first event.
From events-after-page.json — status 200:
{
"items": [
{
"event_ulid": "01M5BSZ80EZ70B79005YCA0GB8",
"event_type": "resource.created",
"occurred_at": "2026-10-20T04:44:01.422Z",
"actor": {
"resource_id": 24784,
"name": "bid-app-docs"
},
"display": {
"name": "Harbor Homes — kitchen",
"resource_type_id": 176,
"text": "✳ created — Bid"
},
"producer_id": "bid-app-docs"
},
{
"event_ulid": "01M5BSZ80YB87EJT0X8JQ1HSGY",
"event_type": "status.changed",
"occurred_at": "2026-10-20T04:44:01.438Z",
"actor": {
"resource_id": 24784,
"name": "bid-app-docs"
},
"display": {
"from_status_id": null,
"to_status_id": 280,
"from_status_name": null,
"to_status_name": "Draft",
"text": "set to Draft"
},
"producer_id": "bid-app-docs"
},
{
"event_ulid": "01M5BSZ86FC7TJ5X6AX036YKZ4",
"event_type": "property.changed",
"occurred_at": "2026-10-20T04:44:01.615Z",
"actor": {
"resource_id": 24784,
"name": "bid-app-docs"
},
"display": {
"key": "tax_rate_bp",
"from": null,
"to": 825,
"text": "✎ tax_rate_bp: — → 825"
},
"producer_id": "bid-app-docs"
}
],
"nextCursor": "c1MTE2MzgyMy4yMjUyOQ"
}
It never skips an event. Several writes can be in progress at once, and one that started earlier can finish later. A poll only hands out events whose writes have fully finished and that nothing still in progress could land in front of, so an event cannot appear behind a cursor you already hold. The cost is that a just-finished write can take a moment longer to show up — the next poll has it.
Your first poll may repeat a few events you already saw on page one (at most the ones written while you were
reading). Treat events as keyed by event_ulid and drop ones you have.
A cursor is opaque: store it, send it back, never build or edit one. Anything else answers 400:
From events-after-invalid.json — status 400:
{
"error": "invalid_cursor",
"message": "Invalid cursor"
}
One known gap. An event sent through event ingest whose resource could not be identified when it arrived
is attached to that resource later. It then appears in the newest-first history, but it is not handed out to
after polls, because it was written before your cursor. If your integration sends events it cannot yet
resolve, re-read page one occasionally.
What your writes look like
Three fields say who did something, and they answer three different questions. They are worth understanding once, because they are how your integration is accountable later.
| Field | Answers | For your writes |
|---|---|---|
producer_id |
Which system wrote this? | Your key's name — on every change it makes, including the birth of a resource it creates (resource.created and the first status.changed) and of a person it names by actorAlias for the first time. A resource ReSync creates in response to your write — the order a converted bid produces — carries the name of the ReSync process that created it (consequence.worker); a status ReSync advances on its own carries advance.worker. Another integration's key name is shown only when you can read its service identity; otherwise someone. |
actor |
On whose behalf? | A service identity that belongs to your key, named after it. An actor you cannot read keeps its resource_id, reads name: "someone" and carries readable: false. |
provenance |
How is this known? | attested — somebody or something asserted it, as opposed to sensed or inferred. |
So a change made by your integration is distinguishable from one made by a person in the web app, and from one computed by a formula, without anybody having to annotate it.
Naming a different actor. If your system knows which person did the thing, say so — pass
actorResourceId (a person's resource id) or actorAlias ({ system, code }, an identity in your own
system) on the write. The producer stays your key either way: the key is still the system that wrote it.
Omit both and the service identity is used, which is honest when the action really was the software's.
Who may be named. actorResourceId may name any person your key can read — in practice, any person of your
organization. That is how an approval is attributed to the person who approved it (for example an internal acceptance
recorded as bid.accepted_internally). Naming someone outside your organization is refused, and nothing is recorded:
From attestation-403-actor-not-readable.json — status 403:
{
"error": "actor_not_readable"
}
Event types you will meet
| Event type | When |
|---|---|
property.changed |
One property changed. Carries key, from, to — one event per key. |
resource.renamed |
The name changed. Carries from_name, to_name. |
resource.reidentified |
The identifier changed. Carries from_identifier, to_identifier. |
property.unconfirmed |
A confirmed value was superseded by an edit. Your key cannot cause this — it is refused instead. |
status.changed |
The resource moved to a new status. |
component.enrolled |
A resource was put inside a container. A move's enrolment also carries moved_from_resource_id. |
component.exited |
A resource left a container. A move's exit also carries moved_to_resource_id; both halves share one transaction. |
resource.created |
The resource came into existence. When it was created with properties, carries properties (the values it was born with — no separate property.changed follows); when it was created by find-or-create, carries alias: { system, code }. |
resource.archived / resource.unarchived |
Archival state changed. |
resource.tagged / resource.untagged |
A tag was applied to or removed from the resource. Carries tagId and tagName (the tag's name when it happened). |
resource.owner_changed |
The resource's owner changed. Carries fromOwnerId and toOwnerId. |
port.created / port.updated / port.deleted |
A port on the resource was added, changed or removed by hand. Carries resource_port_id, port_name, and on an update changedKeys (plus added_resource_type_id or removed_resource_type_id when the accepted types changed). In the changes filter only. |
port_definition.changed |
A type's port definition changed its own settings. Recorded on the type's resource; carries definitionId, port_name and changedKeys. In the changes filter only. |
resource_type.profile_changed |
A type's card presentation changed — its icon, its key properties, or both. Recorded on the type's resource only (never on a container's history); carries typeId and changedKeys (icon, keyProperties), never the values. In the all and changes filters; never unread. |
resource_type.config_changed |
A type's configuration changed. Today: an emission was declared or removed (kind: "emission", verb created / deleted). Recorded on the type's resource only; carries kind, verb, id, typeId, attestationEventTypeId and eventTypeKey (the event type's code). In the all and changes filters; never unread. |
resource.captured |
A status change fired a transition that declares a capture, and the resource was photographed at that moment — one event every time the transition fires. display carries transitionId, fromStatusId, toStatusId, properties (the resource's own values the transition named) and, when something could not be recorded because the resource's owner cannot read it, unreadable (the setting codes or port names left out); the sentence says how many values were recorded. The photographed settings and attached resources are kept on the record but are not passed into display, since they name other resources. In the all and changes filters; never unread. |
thread.created |
A conversation was started. Recorded on the conversation (subject_key thread:{id}) and, when it belongs to a resource, scoped to that resource — so it can appear in that resource's events. Carries thread_id, creator_resource_id, context_resource_id. In the all and changes filters; never unread. |
thread.participant_added / thread.participant_left |
A person joined or left a conversation (self: true when they left on their own). Same placement as thread.created; carries thread_id and participant_resource_id. |
attestation.refused |
An event your producer sent through event ingest was not recorded: its type is declared, and the resource was in the wrong status for it, or it is not repeatable and was already recorded in the current status. Carries eventType, code (attestation_status_gate_unmet or attestation_not_repeatable) and producerEventId. It takes the place of the refused event, under your producer id, so resending the same event is recognized as a duplicate rather than refused twice. |
lifecycle.config_changed |
A lifecycle's configuration changed — the lifecycle itself, one of its statuses, a transition or a requirement. Recorded on the lifecycle's own history only (never on a resource that runs it, nor on a container); carries kind, verb, id, changed, before / after of the changed fields and the ids it touched (statusId, transitionId, requirementIds …) — typed words such as names and labels are noted as changed, not copied. In the quiet filter only. |
consequence_rule.changed |
A consequence rule (an automatic follow-on, such as the order a converted bid produces) was created, changed or retired. Recorded on the rule's own history only; carries verb, id, changed, before / after and the rule's ids (triggerEventType, subjectTypeId, mintTargetTypeId …). In the quiet filter only. |
derivation.refused |
A computed value or a templated name was not updated. Carries key and code — unique_key_taken, status_read_only, derive_target_not_derivable, or setting_name_unreadable (the name would use a setting value the resource's owner cannot read). In the changes filter. |
| your attestation types | Whatever POST /v1/resources/:id/attestations recorded — the codes your organization has declared. Read them from GET /v1/attestation-event-types. A declaration can change, so read it rather than caching it — since 2026-10-17 bid.sent declares the six totals and tax_rate_bp only (customer_pct_bp was removed; the price level and payment terms are now recorded from the bid's own fields when it is sent). |
A sent bid's newest history, as a key reads it — the status change, the snapshot Submit took, and bid.sent with the
current declaration's fields:
From events-grant-owned.json — status 200:
{
"items": [
{
"event_ulid": "01M5BSZB3DC81MZPA01G0N139B",
"event_type": "status.changed",
"occurred_at": "2026-10-20T04:44:04.589Z",
"actor": {
"resource_id": 24784,
"name": "bid-app-docs"
},
"display": {
"from_status_id": 280,
"to_status_id": 1314,
"from_status_name": "Draft",
"to_status_name": "Sent",
"text": "Draft → Sent"
},
"producer_id": "bid-app-docs"
},
{
"event_ulid": "01M5BSZB3B3T05393WCWM19QDJ",
"event_type": "resource.captured",
"occurred_at": "2026-10-20T04:44:04.587Z",
"actor": {
"resource_id": 24784,
"name": "bid-app-docs"
},
"display": {
"transitionId": 818,
"fromStatusId": 280,
"toStatusId": 1314,
"properties": {
"tax_rate_bp": 825
},
"text": "Recorded 12 values on Submit"
},
"producer_id": "bid-app-docs"
},
{
"event_ulid": "01M5BSZAZ3SV3132XNPYTKV77X",
"event_type": "bid.sent",
"occurred_at": "2026-10-20T04:44:04.451Z",
"actor": {
"resource_id": 24784,
"name": "bid-app-docs"
},
"display": {
"tax_cents": 578,
"tax_rate_bp": 825,
"total_cents": 7579,
"install_cents": 0,
"delivery_cents": 0,
"discount_cents": 0,
"subtotal_cents": 7001,
"attested_action_label": "Send bid"
},
"producer_id": "bid-app-docs"
}
],
"nextCursor": "01M5BSZAZ3SV3132XNPYTKV77X",
"afterCursor": "c1MTE2MzgzNy4yMjU0OA"
}
Each item also carries a prepared display sentence, so you can show history to a human without writing a
formatter per event type. The sentence and every name in display follow the
names you cannot read rule: something you cannot read is someone,
a resource or a type, with its id kept and readable: false (sourceReadable / targetReadable on a connection
event). For the event types the platform writes, display carries a fixed set of keys; any other key in the stored
event is left out.
Two things to know
One PATCH can produce several events. Change three properties and rename a resource in one request
and you get four events, not one. They share a timestamp and appear together.
A refused write produces none. When a request is rejected — a confirmed property, a computed
property, a read-only status, a scope refusal — nothing is appended. There is no record of the attempt, so if
you need to know that your integration tried and failed, log it on your side. The one exception is event
ingest, which answers 202 before the event is processed: a declared event refused there is recorded as
attestation.refused (above), so the refusal is visible on the resource's history. Ingest checks only the
event's status and repeat rules; the payload-field checks of POST /v1/resources/:id/attestations apply to
that door only. An event whose subject cannot be resolved when it arrives is recorded unchecked.