The change feed
The ordered, replayable log of every change you can see, read with a cursor, long-polled with wait, or streamed live over server-sent events.
Every change FurniOps makes to data you can see is written, in the same transaction, to a log: the change feed. It holds the events of your own catalogue, of the network catalogues your licences cover, and your sources' health, in commit order. It is the source of truth for keeping a copy in sync; webhooks and the stream deliver the same events faster but can be missed.
The event envelope
The feed, webhooks and the stream all carry the same envelope:
{
"id": "evt_034XqBaL2mRLTZPXyRbaUi",
"type": "offer.changed",
"spec_version": "1.0",
"source": "furniops/catalog",
"subject": "ofr_034XqBaL2mRLTZPXyRbaUg",
"time": "2026-10-04T10:00:02Z",
"tenant_id": "org_034XqBaL2mRLTZPXyRbaUj",
"version": 12,
"schema_version": 1,
"correlation_id": "run_034XqBaL2mRLTZPXyRbaUk",
"causation_id": null,
"livemode": true,
"data": {
"id": "ofr_034XqBaL2mRLTZPXyRbaUg",
"variant": "var_034XqBaL2mRLTZPXyRbaUf",
"source": "src_034XqBaL2mRLTZPXyRbaUh",
"catalogue": "cat_034XqBaL2mRLTZPXyRbaUd",
"location": "DAL",
"availability": "in_stock",
"version": 12,
"facets": ["price"],
"fields": [
{
"path": "cost",
"old": 89900,
"new": 94900,
"provenance": {
"source": "src_034XqBaL2mRLTZPXyRbaUh",
"method": "mapped",
"confidence": 1,
"observed_at": "2026-10-04T10:00:00Z"
}
}
]
}
}
| Field | Meaning |
|---|---|
id | evt_…, globally unique. The deduplication key: process each ID once. |
type | What happened, such as offer.changed. See event types. |
source | Which part of FurniOps emitted it, such as furniops/catalog. |
subject | The ID of the object that changed. Events of one subject are in order. |
version | The object's version after this change. Apply only versions newer than yours. |
time | When the change was recorded (UTC). |
tenant_id | Your organisation's ID (org_…). |
livemode | false for test mode data. |
correlation_id | The request, fetch or run that caused it, when there is one. |
causation_id | The event that caused this one, when there is one. |
spec_version, schema_version | The envelope's version, and this type's data schema version. |
data | The change, shaped by type: the object's IDs and version, and for updates fields, each changed field's path, old and new value with its provenance. Each type's shape is in the API reference. |
Thin and fat payloads
By default (payload=thin) an event carries only what changed. With payload=fat, data also
carries object: the product, variant or offer as of the event's version (without its children),
as your licences let you see it, so you never need to read it back. object is left out of an
event when a later version in the same change followed it. Webhook endpoints choose with their
payload field; the feed and the stream with the payload query parameter.
Reading the feed
GET /v1/changes answers a page of events and a cursor. Pass the cursor back to read on:
curl "https://api.furniops.com/v1/changes?limit=500&cursor=$CURSOR" \
-H "Authorization: Bearer $FURNIOPS_API_KEY"{
"object": "change_page",
"data": [
{
"id": "evt_034XqBaL2mRLTZPXyRbaUn",
"type": "product.updated",
"spec_version": "1.0",
"source": "furniops/catalog",
"subject": "prod_034XqBaL2mRLTZPXyRbaUc",
"time": "2026-10-04T11:00:04Z",
"tenant_id": "org_034XqBaL2mRLTZPXyRbaUj",
"version": 13,
"schema_version": 1,
"correlation_id": "run_034XqBaL2mRLTZPXyRbaUo",
"causation_id": null,
"livemode": true,
"data": {
"id": "prod_034XqBaL2mRLTZPXyRbaUc",
"catalogue": "cat_034XqBaL2mRLTZPXyRbaUd",
"status": "active",
"version": 13,
"fields": [
{
"path": "description",
"old": "A deep-seated sofa with feather cushions.",
"new": "A deep-seated sofa with feather-wrapped cushions and a kiln-dried hardwood frame.",
"provenance": {
"source": "src_034XqBaL2mRLTZPXyRbaUh",
"method": "mapped",
"confidence": 1,
"observed_at": "2026-10-04T11:00:00Z"
}
}
]
}
}
],
"cursor": "eyJ0IjoiMTIzNDU2Nzg5MCIsImkiOjQyfQ",
"caught_up": true
}
| Parameter | Default | Meaning |
|---|---|---|
cursor | the oldest retained event | Where the previous page ended. |
limit | 100 | Events per page, 1 to 1000. |
types | every type | Comma-separated types to keep: offer.changed,offer.created. |
catalogues | every catalogue you can see | Comma-separated cat_ IDs, at most 50: only events of those catalogues. A source health event belongs to the catalogue its source feeds. |
wait | 0 | Seconds (0 to 25) to wait for an event when nothing is left to read. |
payload | thin | thin or fat. |
caught_up: truemeans nothing committed is left aftercursor. Poll again later with that cursor, or long-poll withwait.cursoralways moves forward, so save the one each page answers, even a page with no events. A page that is not caught up ends after its last event. A caught-up page ends at the feed's horizon: the point every later commit sorts after. A page that matched nothing (a quietcataloguesortypesfilter, or events your licences do not cover) therefore still moves on.- Cursors are opaque: pass them back as they are, and never compare or decode them.
- The key needs the
events:readscope.
Long polling with wait
With wait=25, a read that finds nothing holds the connection until an event commits, then
answers at once; or answers empty, with caught_up: true, when 25 seconds pass. A loop of
long-polls gets changes within moments, with no busy polling. Too many waiting reads answer 429
(your organisation's) or 503 (the service's), with Retry-After.
Retention
Events are kept 30 days, or fewer when your plan
says so. A cursor older than that answers 400. A reader that keeps reading never holds one that
old, because every page moves the cursor forward, however quiet the catalogues it follows. A copy
left behind longer starts again from a fresh read of GET /v1/products (with updated_since), then
follow the feed. Sync a catalogue shows the full pattern.
Newest first
For a "recent changes" view, GET /v1/changes/recent
returns the same events newest first, as your licences let you see them. Pass next_cursor back as
starting_after for older ones; a page can hold fewer than limit events and still have more.
The first page's cursor reads on forwards from just after its newest event, with
GET /v1/changes or the stream. Only events your plan still keeps are listed.
The live stream
GET /v1/stream serves the same events as server-sent events
(text/event-stream), for dashboards, consoles and local tools:
id: eyJ0IjoiMTIzNDU2Nzg5MCIsImkiOjQyfQ
event: offer.changed
data: {"id":"evt_…","type":"offer.changed", …}
: heartbeat
id: eyJ0IjoiMTIzNDU2Nzg5MSIsImkiOjB9eventis the event type,datathe envelope exactly as the feed shows it, andida feed cursor.- While the stream is quiet, the server sends a
: heartbeatcomment every 15 s. When the stream has read on past its last event (events your filters or licences skip, or up to the feed's horizon), the heartbeat is followed by a frame with only anid: the position it reached. The stream's end sends one too. These frames carry no event, butEventSourceand the SDK'sstream.lastEventIdmove on with them, so a reconnection does not read again what was skipped and a quiet stream's resume point keeps moving forward. - Without a cursor the stream starts with events committed from now on. With
cursor(or aLast-Event-IDheader on reconnection) it carries on after that position. - It takes the same
types,cataloguesandpayloadparameters as the feed. - The server ends a stream after a while; reconnect with the last
id(your access is checked again). The browser'sEventSourcecannot send a bearer token, so read it withfetch, or with the SDK'schanges.stream, which reconnects for you. - Opening a stream is in the
streamrate class; each reconnection counts.
The stream is not a delivery guarantee
A stream is best effort: a dropped connection can lose events between the drop and your
reconnection unless you resume from the last id. Systems that must not miss an event follow
the feed or receive webhooks.
Event types
| Type | Sent when |
|---|---|
| source.degraded | A connection to a vendor's data |
| source.recovered | A connection to a vendor's data |
| source.blocked | A connection to a vendor's data |
| product.created | A master product's change: what moved, and to what |
| product.updated | A master product's change: what moved, and to what |
| product.discontinued | A master product's change: what moved, and to what |
| product.published | A master product's change: what moved, and to what |
| product.withdrawn | A master product's change: what moved, and to what |
| product.merged | A master product merged into another: it now redirects to `merged_into` |
| product.unmerged | A merged master product is its own again: every field, and the survivor it left |
| variant.created | A master variant's change: what moved, and to what |
| variant.updated | A master variant's change: what moved, and to what |
| variant.merged | A master variant merged into another: it now redirects to `merged_into` |
| variant.unmerged | A merged master variant is its own again: every field, and the survivor it left |
| offer.created | A source's offer changed: price, stock or availability |
| offer.changed | A source's offer changed: price, stock or availability |
| catalogue.verified | Whether a vendor catalogue is verified by its brand |
| catalogue.unverified | Whether a vendor catalogue is verified by its brand |
The events reference lists every event with the channels that deliver it, and links each to its payload.
Merges and unmerges are explained in Master records.
Webhook endpoints also receive webhook.test when you
send them a test event.
Organisation events
Some events are about your organisation rather than a catalogue. No licence narrows them, the
change feed and the stream do not carry them, and only webhooks deliver them, to your endpoints of
the event's mode that subscribe to the type (or to *):
| Type | Sent when |
|---|---|
delivery.disabled | FurniOps disabled one of your webhook endpoints: no delivery succeeded for 72 hours, or it answered 410 Gone. Sent to your other endpoints. |
usage.threshold_reached | A meter reached 80% or 100% of its monthly quota, in live mode. See Usage and quotas. |
export.completed | A bulk export succeeded or failed. See Bulk exports. |
proposal.created | An AI agent proposed a change that passed its proof and waits for a person. See Proposals. |
proposal.applied | A proposal was applied, by a person or at once as low risk. See Proposals. |
They cannot be triggered with POST /v1/test/events.
Ordering and delivery guarantees
- The feed is in commit order, and complete for the days it keeps.
- Events of one subject are delivered to a webhook endpoint in order; across subjects, deliveries run in parallel.
- Every path (feed, stream, webhooks, the read API) applies the same licence projection: you see the same fields everywhere.
- Delivery is at least once: the same event can arrive twice. Deduplicate by
id, and apply byversion.
Freshness
How FurniOps decides how often to check each source, how you tell it what matters now, and how to ask for a fresh stock check.
API versions
Dated API versions, the pin your organisation gets on its first request, the FurniOps-Version header, and how webhook endpoints choose the shape of their events.