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

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.