Events

An event records that something in your team changed, and carries a snapshot of the object as it stood immediately afterwards. Together they form the team's change feed: a time-ordered, resumable log you can poll, replay, or have pushed to you as webhooks — and the team's record of who changed what.

Change history

The feed is the team's record of who changed what. Every event names the actor, the time it was recorded and the call that caused it, and a passport.updated event also carries the values the changed fields had before, in data.previous_attributes.

QuestionWhere the answer is
Whoactor.user_id, plus actor.api_key_id when a key made the call
Whencreated
Which callrequest_id — the X-Request-Id that call's response carried
What changeddata.previous_attributes against data.object

To reconstruct how one object reached its current state, filter the feed to it: GET /v1/events?object_id=pass_3e9a1c5b7d2f4083 returns every event about that passport, newest first. actor.user_id is the id GET /v1/team/members reports, which is how an actor becomes a name.

Three limits apply. Reads are not recorded — retrieving, listing or finding a passport leaves no trace in the feed or anywhere else. Batch work is recorded per batch, not per passport: rows an import wrote, passports a release cascade stamped and values a Lab transform changed appear on the import's, the run's or the Lab run's own events, not under the passport's id (see one event per call). Their per-passport detail lives on the import's rows and the Lab run's changes. And the history is as long as retention and no longer: 30 days. If you need it for longer, keep it yourself — subscribe a webhook endpoint or run a poller and store what arrives.

The event model

  • Name
    id
    Type
    string
    Description

    The evt_ identifier. K-sortable, and the deduplication key for webhook consumers.

  • Name
    object
    Type
    string
    Description

    Always event.

  • Name
    type
    Type
    string
    Description

    A dotted type from the catalog.

  • Name
    created
    Type
    string
    Description

    When the change was recorded, ISO 8601 UTC.

  • Name
    data
    Type
    object
    Description

    Carries object, the snapshot of the subject; previous_attributes on an update whose prior values are known (see previous attributes); and, only when the snapshot was too large, truncated: true.

  • Name
    request_id
    Type
    string
    Description

    The req_ id of the API call that caused the change, or null for background work such as an import's terminal event or an auto-disable.

  • Name
    actor
    Type
    object
    Description

    { user_id, api_key_id }, both keys always present. user_id is the person who acted; api_key_id is the key used, or null when the change came from the Synex application. Either may be null — see two teams, one change.

These are the exact bytes a webhook delivery POSTs. An event fetched from GET /v1/events/{event} and the same event received on your webhook endpoint are byte-identical, which is what lets you build against the feed first and turn on delivery later.

Snapshots

data.object is produced by the same code that serves the object's own GET, so it is byte-identical to what that endpoint would have returned at the moment of emission. A passport.updated event carries the whole passport, not a diff — you rarely need a follow-up call to find out what the object now looks like.

Two consequences worth internalising. First, the snapshot is a point-in-time value and the object may have changed again since; if you need current truth, re-fetch. Second, the snapshot reflects the object as the emitting code saw it — lab.enabled and lab.disabled have no read endpoint behind them and carry a purpose-built payload instead, {object, team, enabled, enabled_at}.

design.updated is the other side of that coin. It does have a read endpoint behind it, so its snapshot is the ordinary kind: byte-identical to what GET /v1/design would have returned at that moment.

api_key.revoked is a third case: its snapshot is the ordinary kind, but the row it describes no longer exists — so re-fetching returns 404 and the snapshot is the only record. All three api_key.* payloads omit the key's secret, which the key resource structurally cannot render.

A translated passport value appears the way a GET without lang renders it: in the ontology's default language, with language and is_fallback beside value and no translations map. Re-fetch with ?lang=all when you need every language.

Previous attributes

An update event can say what the object looked like before as well as after. data.previous_attributes holds the prior values of the fields that changed, in the same shape as data.object — and only those fields. It is present only on *.updated events whose prior values are known, which today means passport.updated; on every other event it is absent.

Previous attributes

{
  "type": "passport.updated",
  "data": {
    "object": {
      "object": "passport",
      "id": "pass_3e9a1c5b7d2f4083",
      "friendly_id": "BAT-002",
      "publish_status": "released",
      "values": {
        "prop_ab12": { "value": 13.1 },
        "prop_cd34": { "value": "LFP" }
      }
    },
    "previous_attributes": {
      "friendly_id": "BAT-001",
      "publish_status": "draft",
      "values": {
        "prop_ab12": { "value": 12.5 },
        "prop_cd34": null
      }
    }
  }
}

On a passport it can carry friendly_id, publish_status and values; metadata is never among them, so a call that changes only metadata emits a passport.updated with no previous_attributes. Under values, a property that had no value before the change appears as null — prop_cd34 above was set for the first time. A field that did not change is not listed, so a key's absence means "unchanged", not "was empty".

Because one API call emits one event, a PATCH that changes values, friendly_id and publish_status together produces a single event whose previous_attributes holds the value each field had before the request — not some intermediate state from partway through it. Diffing previous_attributes against data.object gives you exactly what that call changed.

Translated values are compared in the same default-language rendering as the snapshot. A write that changes only another language's translation still lists the property, with the same value on both sides; re-fetch with ?lang=all to see which language moved.

Truncated snapshots

A snapshot over 64 KB is not dropped; it degrades. data.object becomes a thin reference carrying only id and object, and data.truncated appears set to true:

Truncated event

{
  "type": "passport.updated",
  "data": {
    "object": { "object": "passport", "id": "pass_3e9a1c5b7d2f4083" },
    "truncated": true
  }
}

Treat the presence of data.truncated as the instruction to re-fetch the object from its own endpoint. It is absent — not false — on a normal event, so test for presence rather than for a truthy value. A consumer that reads data.object fields blindly will silently see nulls on large objects, so this is worth handling on day one even if your objects are small today.

data.previous_attributes survives truncation when it fits within the size cap on its own, and is dropped when it does not — so a truncated event may or may not carry it. It is the one part a re-fetch cannot recover.

One event per call

Events are emitted once per API call, not once per changed field. A single PATCH /v1/passports/{passport} that changes property values, friendly_id, publish_status and metadata produces exactly one passport.updated, carrying the final state after all four changes.

Three related rules complete the picture:

  • Reads never emit. Retrieving a passport is not recorded — here or anywhere else. Nothing you do with a GET will ever appear here.
  • Batch work emits batch-level events only. A 50,000-row import produces import.started and one terminal event — never 50,000 passport.created events; walk GET /v1/imports/{import}/rows for per-row detail. A release cascade over 20,000 passports likewise produces release_run.started and one terminal release_run.* event, while releasing a single passport is an ordinary edit and emits passport.updated. The exception is a bulk data request: its children are real data requests and emit their own submit, apply and cancel events beside the set's.
  • Idempotency replays never re-emit. A replayed POST returns the stored response without re-executing, so it cannot produce a second event. See idempotency.

Retention

Events are kept for 30 days, then pruned. That is one platform-wide window, the same for every team; there is no per-team retention setting — deliberately, because a webhook replay window has to mean the same thing for every consumer and every integration built against the API.

A pruned event returns 404 from GET /v1/events/{event}. A webhook delivery record can outlive the event it refers to, which is one reason a consumer reconciles by re-fetching the object rather than the event.

If your integration can be down for longer than 30 days, the feed is not your recovery mechanism — reconcile by listing the objects themselves.


GET/v1/events

List events

The team's change feed, newest first. Requires events:read. Returns a paginated list.

Filters combine with AND — except type and types[], which are two spellings of one filter and combine as a union. An unknown value for either is rejected with 400 parameter_invalid rather than returning an empty page, so a typo in a poller surfaces immediately instead of looking like silence.

Every filter joins the cursor's scope: a cursor minted under one set of filters is rejected with 400 invalid_cursor if replayed under another. Keep the filters identical for the whole walk and vary only limit.

Optional attributes

  • Name
    type
    Type
    string
    Description

    A single event type from the catalog.

  • Name
    types[]
    Type
    array
    Description

    Several types; repeat the parameter per value. Combines with type as a union, not an intersection.

  • Name
    object_id
    Type
    string
    Description

    Only events whose subject is this object — the id of a passport, template, ontology, composition, import, file, API key, webhook endpoint, supplier link (sup_), data request (dreq_), bulk data request (dset_), release run or Lab run. Indexed, not scanned.

  • Name
    created[gte]
    Type
    string
    Description

    Only events at or after this time. Unix seconds or an ISO date, inclusive of the whole second named. Later than created[lte] is 400 parameter_invalid.

  • Name
    created[lte]
    Type
    string
    Description

    Only events at or before this time.

  • Name
    limit
    Type
    integer
    Description

    Page size, 1–100 (default 10).

  • Name
    cursor
    Type
    string
    Description

    The previous response's next_cursor, verbatim.

Request

GET
/v1/events
curl -G https://api.synexcloud.com/v1/events \
  -H "Authorization: Bearer $SYNEX_API_KEY" \
  -d "types[]=passport.created" \
  -d "types[]=passport.updated" \
  -d "created[gte]=2026-07-01" \
  -d "limit=20"

Response

{
  "object": "list",
  "data": [
    {
      "id": "evt_2c7f9a4e1b8d3506",
      "object": "event",
      "type": "passport.updated",
      "created": "2026-07-22T14:18:02Z",
      "data": {
        "object": {
          "object": "passport",
          "id": "pass_3e9a1c5b7d2f4083",
          "friendly_id": "PACK-24-0917",
          "publish_status": "released",
          "release_number": 1,
          "values": { "property_capacity": { "value": 78.4 } }
        },
        "previous_attributes": {
          "publish_status": "draft",
          "values": { "property_capacity": { "value": 76.9 } }
        }
      },
      "request_id": "req_0a9f3c2b1e4d7f0a",
      "actor": { "user_id": 118, "api_key_id": 42 }
    }
  ],
  "has_more": true,
  "next_cursor": "evt_2c7f9a4e1b8d3506",
  "url": "/v1/events"
}

GET/v1/events/:id

Retrieve an event

Reads one event by its evt_ id. Requires events:read.

An event outside the 30-day retention window is 404 resource_missing, and so is another team's event — including the other team's copy of a two-team event.

Request

GET
/v1/events/evt_2c7f9a4e1b8d3506
curl https://api.synexcloud.com/v1/events/evt_2c7f9a4e1b8d3506 \
  -H "Authorization: Bearer $SYNEX_API_KEY"

Response

{
  "id": "evt_2c7f9a4e1b8d3506",
  "object": "event",
  "type": "passport.updated",
  "created": "2026-07-22T14:18:02Z",
  "data": {
    "object": {
      "object": "passport",
      "id": "pass_3e9a1c5b7d2f4083",
      "friendly_id": "PACK-24-0917",
      "publish_status": "released",
      "release_number": 1,
      "values": { "property_capacity": { "value": 78.4 } }
    },
    "previous_attributes": {
      "publish_status": "draft",
      "values": { "property_capacity": { "value": 76.9 } }
    }
  },
  "request_id": "req_0a9f3c2b1e4d7f0a",
  "actor": { "user_id": 118, "api_key_id": 42 }
}

A resumable poller

Because ids are k-sortable and the feed is ordered on them, the last event id you processed is a durable high-water mark. Poll newest-first, stop when you reach an id you have already seen, and persist the newest id of the batch:

  1. GET /v1/events?limit=100 with your filters.
  2. Process rows until you hit your stored high-water mark id.
  3. If you did not reach it and has_more is true, follow next_cursor.
  4. Store the newest id from the run and sleep until the next tick.

The created[gte] / created[lte] window is applied as bounds on the event id rather than as a scan over timestamps, so a narrow window is exact and cheap — backfilling one day out of the retained thirty costs about what reading that day costs.

For push instead of polling, register a webhook endpoint. The two are the same data; webhooks add delivery, signing and retries, and cost you a public HTTPS endpoint.

Event catalog

Forty-six types today. New ones are added over time, so a consumer that switches on type must tolerate an unrecognised value rather than failing — and a webhook endpoint subscribed with "*" receives types added after it was registered.

Most types fire on the acting team's feed only. Those marked both feeds fire on the customer's and the supplier's feed from the same change; see two teams, one change.

TypeFires when
passport.createdA passport is created, individually or as an ontology's root.
passport.updatedA passport's values, friendly_id, publish status or metadata change — one event per call, with previous_attributes. Releasing or un-releasing a single passport is one of these, as is a data request's values landing on it.
passport.deletedA passport is deleted.
template.createdA template is created or forked from another.
template.updatedA draft template's structure changes, or a completed template's metadata does.
template.deletedA template is deleted.
ontology.createdAn ontology is instantiated from a completed template.
ontology.updatedAn ontology's metadata is patched — today the only direct edit an ontology accepts.
ontology.version.publishedA draft is published as a new schema version; the snapshot carries the version payload including its number.
composition.createdA composition layout is created for an ontology.
composition.updatedA composition layout is changed, or one language of its translations is written or deleted. A layout change does not surface as passport.updated. The snapshot carries every language's strings, so a translated composition's event can be an order of magnitude larger than an untranslated one, and correspondingly more likely to arrive truncated.
import.startedA mass import is accepted and begins processing.
import.completedAn import finishes with every row applied.
import.completed_with_errorsAn import finishes with at least one rejected row; walk the import's rows for detail.
import.failedAn import fails outright, applying nothing.
import.rows.retriedPreviously failed rows of an import are resubmitted.
release_run.startedA release cascade begins — include_descendants on a passport release, or the reverse when a release is taken back. The snapshot is the release run, as GET /v1/release_runs/{release_run} returns it.
release_run.completedA cascade finishes. partial runs report here too: the work is over, and failed_count says how cleanly.
release_run.failedA cascade could not run at all — its root or ontology was gone, enumeration broke, or it stalled and was swept.
lab.transform.completedA Lab transform run finishes successfully.
lab.transform.failedA Lab transform run fails.
lab.rollback.completedA Lab rollback finishes, reverting a previous run.
lab.enabledSynex Lab is enabled for the team; payload is {object, team, enabled, enabled_at}.
lab.disabledSynex Lab is disabled for the team; same purpose-built payload.
webhook_endpoint.disabledAn endpoint is auto-disabled after failing continuously for five days. Delivered to your other healthy endpoints, never to the one being disabled.
api_key.createdAn API key is minted through POST /v1/api_keys. The snapshot never carries the key's secret.
api_key.rolledA key is rolled, by either roll endpoint. Emitted on the replacement, whose rolled_from names the retiring key — so one event gives you both ids. No secret, again.
api_key.revokedA key is revoked. The snapshot is its final state; the row itself is already gone, so this is one of the few payloads that cannot be re-fetched. There is deliberately no api_key.updated.
design.updatedThe team's public-passport theme changes — palette, rounding, shadow, logo, fonts, preset, mode, colours, labels, navigation, hero, frames, component styles and options, or a part style (top bar, masthead, chapters, sections, fields, figures, charts, rhythm). The snapshot is the whole design.
file.createdA file becomes live in the Drive, from either ingestion path: a single-shot POST /v1/files or the completion of a multipart upload. Folders emit nothing.
supplier.invitedA pending supplier link gets its invitation — POST /v1/suppliers, POST /v1/data_requests with a recipient, or the first invitation a resend_invite issues to a link that never had one. Customer feed.
supplier.linkedThe invited recipient accepts, in the web application, and the link goes linked. Both feeds.
supplier.revokedThe customer revokes a link. Customer feed, and the supplier's when the link had been accepted. Every request it cancels emits data_request.cancelled too.
data_request.createdA data request is opened on its own. Customer feed.
data_request.receivedA request reaches a supplier team's inbox — at create over a linked supplier, or, for every open request over a link, the moment its invitation is accepted. Supplier feed.
data_request.submittedThe supplier submits or resubmits. Customer feed; the snapshot is taken after any automatic apply, so it already says whether the values landed or were held back.
data_request.returnedThe customer returns the submission for revision, by POST …/return or by an apply that leaves a rejected value behind. Supplier feed only — the customer's feed does not hear what the customer did.
data_request.appliedThe request reaches applied — by the customer's apply, or by the submit of an auto_apply request. Both feeds.
data_request.cancelledThe request is cancelled — by the customer, by a link revoke, or by the passport's deletion. Customer feed, and the supplier's when a supplier team already held the request.
data_request_set.createdA bulk data request's create job has made its children. Customer feed. This one event announces the children: they emit no data_request.created or data_request.received of their own when they are made.
data_request_set.receivedA bulk request reaches a supplier team — at create over a linked supplier, or the moment the link's invitation is accepted. Supplier feed.
data_request_set.submittedA bulk submission (submit_all, or an upload with submit_on_upload) submitted anything. Customer feed. Each child emits its own data_request.submitted.
data_request_set.appliedAn apply_all applied anything. Both feeds. Each child emits its own data_request.applied.
data_request_set.cancelledThe customer cancels a bulk request. Customer feed, and the supplier's when a supplier team held it. Each child emits its own data_request.cancelled.
passport.transferredA passport transfer in Move mode finishes: the passports now belong to the destination team. Both feeds, once per transfer, never once per passport; the payload is the transfer id, the source and destination ontology ids and the counts. Catalogued ahead of transfers, which are not yet available — nothing emits it today.
passport.copiedA passport transfer in Copy mode finishes: the destination team holds new passports under new ids and the source is untouched. Both feeds, once per transfer, same payload. Not emitted yet either.

Remember the two catalog-wide rules when reading that list: no type fires on a read, and no type fires per-row inside a mass import or per-passport inside a release cascade.

Two teams, one change

A supplier link and a data request belong to two teams at once — the customer that asks and the supplier that answers — so the types the catalog marks both feeds are emitted to both teams from one change. Four things follow, and they are worth knowing before you build on them.

Each feed gets its own event. A data_request.applied produces one row on the customer's feed and one on the supplier's, each with its own evt_ id and its own webhook deliveries. The two share type, request_id and the object's id; nothing else is guaranteed to be identical, and a consumer sees only its own team's row. object_id filters both feeds by the same sup_, dreq_ or dset_ id.

The snapshot is the object as that side reads it. data.object is what a GET of the link or request from that team would return at that instant — a supplier.* snapshot carries role: customer on one feed and role: supplier on the other — with three subtractions on a data request. The presigned link a single read puts on file and image values never travels on the feed, since a 24-hour credential has no place in a 30-day log; the customer's copy does not carry current_value, because the passport is not read to emit an event; and there is no viewer_role, which the read endpoint stamps and the snapshot does not. Tell the sides apart by the feed you are reading, or by comparing customer_team.id and supplier_team.id with your own team's id. Re-fetch the request for any of the three.

The actor is the real person who acted, on both feeds. When Nordvolt's engineer submits, the customer's data_request.submitted names her user_id even though she is not a member of the customer team — so it is not an id the customer's GET /v1/team/members returns. actor.api_key_id is set only on the feed of the team the key is pinned to: on the other team's copy of the same event it is null, because no key of theirs was involved. The same holds when her auto_apply submission writes to the customer's passport: the passport.updated lands on the customer's feed only, naming her, with a null api_key_id. An acceptance in the web application (supplier.linked, and the .received events it triggers) has a null api_key_id on both feeds, like every change made in the application. A bulk request's create, submit and apply run as queued jobs, so the events they record — the set's and its children's — name the user who started the job, with a null api_key_id and a null request_id.

One case has no actor at all. When a request is cancelled as a side effect — its link revoked, or its passport deleted — the customer's feed names the key and user behind the revoke or delete, but the supplier's copy of data_request.cancelled carries user_id: null and api_key_id: null, and the request's own cancelled_by is null too: nobody on the supplier's side did anything, and the customer's context does not extend to their feed. Read the actor as optional everywhere and the question never comes up again.

Was this page helpful?