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.
events:read is not in a key's default ability set — grant it explicitly
when minting the key. See authentication.
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.
| Question | Where the answer is |
|---|---|
| Who | actor.user_id, plus actor.api_key_id when a key made the call |
| When | created |
| Which call | request_id — the X-Request-Id that call's response carried |
| What changed | data.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_attributeson 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_idis the person who acted;api_key_idis 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
GETwill ever appear here. - Batch work emits batch-level events only. A 50,000-row import produces
import.startedand one terminal event — never 50,000passport.createdevents; walkGET /v1/imports/{import}/rowsfor per-row detail. A release cascade over 20,000 passports likewise producesrelease_run.startedand one terminalrelease_run.*event, while releasing a single passport is an ordinary edit and emitspassport.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
POSTreturns 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.
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
typeas 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]is400 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
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"
}
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
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:
GET /v1/events?limit=100with your filters.- Process rows until you hit your stored high-water mark id.
- If you did not reach it and
has_moreis true, follownext_cursor. - 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.
| Type | Fires when |
|---|---|
passport.created | A passport is created, individually or as an ontology's root. |
passport.updated | A 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.deleted | A passport is deleted. |
template.created | A template is created or forked from another. |
template.updated | A draft template's structure changes, or a completed template's metadata does. |
template.deleted | A template is deleted. |
ontology.created | An ontology is instantiated from a completed template. |
ontology.updated | An ontology's metadata is patched — today the only direct edit an ontology accepts. |
ontology.version.published | A draft is published as a new schema version; the snapshot carries the version payload including its number. |
composition.created | A composition layout is created for an ontology. |
composition.updated | A 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.started | A mass import is accepted and begins processing. |
import.completed | An import finishes with every row applied. |
import.completed_with_errors | An import finishes with at least one rejected row; walk the import's rows for detail. |
import.failed | An import fails outright, applying nothing. |
import.rows.retried | Previously failed rows of an import are resubmitted. |
release_run.started | A 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.completed | A cascade finishes. partial runs report here too: the work is over, and failed_count says how cleanly. |
release_run.failed | A cascade could not run at all — its root or ontology was gone, enumeration broke, or it stalled and was swept. |
lab.transform.completed | A Lab transform run finishes successfully. |
lab.transform.failed | A Lab transform run fails. |
lab.rollback.completed | A Lab rollback finishes, reverting a previous run. |
lab.enabled | Synex Lab is enabled for the team; payload is {object, team, enabled, enabled_at}. |
lab.disabled | Synex Lab is disabled for the team; same purpose-built payload. |
webhook_endpoint.disabled | An endpoint is auto-disabled after failing continuously for five days. Delivered to your other healthy endpoints, never to the one being disabled. |
api_key.created | An API key is minted through POST /v1/api_keys. The snapshot never carries the key's secret. |
api_key.rolled | A 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.revoked | A 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.updated | The 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.created | A 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.invited | A 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.linked | The invited recipient accepts, in the web application, and the link goes linked. Both feeds. |
supplier.revoked | The 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.created | A data request is opened on its own. Customer feed. |
data_request.received | A 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.submitted | The 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.returned | The 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.applied | The request reaches applied — by the customer's apply, or by the submit of an auto_apply request. Both feeds. |
data_request.cancelled | The 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.created | A 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.received | A 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.submitted | A 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.applied | An apply_all applied anything. Both feeds. Each child emits its own data_request.applied. |
data_request_set.cancelled | The 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.transferred | A 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.copied | A 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.