FurniOpsDocs

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"
        }
      }
    ]
  }
}
FieldMeaning
idevt_…, globally unique. The deduplication key: process each ID once.
typeWhat happened, such as offer.changed. See event types.
sourceWhich part of FurniOps emitted it, such as furniops/catalog.
subjectThe ID of the object that changed. Events of one subject are in order.
versionThe object's version after this change. Apply only versions newer than yours.
timeWhen the change was recorded (UTC).
tenant_idYour organisation's ID (org_…).
livemodefalse for test mode data.
correlation_idThe request, fetch or run that caused it, when there is one.
causation_idThe event that caused this one, when there is one.
spec_version, schema_versionThe envelope's version, and this type's data schema version.
dataThe 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
}
ParameterDefaultMeaning
cursorthe oldest retained eventWhere the previous page ended.
limit100Events per page, 1 to 1000.
typesevery typeComma-separated types to keep: offer.changed,offer.created.
cataloguesevery catalogue you can seeComma-separated cat_ IDs, at most 50: only events of those catalogues. A source health event belongs to the catalogue its source feeds.
wait0Seconds (0 to 25) to wait for an event when nothing is left to read.
payloadthinthin or fat.
  • caught_up: true means nothing committed is left after cursor. Poll again later with that cursor, or long-poll with wait.
  • cursor always 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 quiet catalogues or types filter, 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:read scope.

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: eyJ0IjoiMTIzNDU2Nzg5MSIsImkiOjB9
  • event is the event type, data the envelope exactly as the feed shows it, and id a feed cursor.
  • While the stream is quiet, the server sends a : heartbeat comment 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 an id: the position it reached. The stream's end sends one too. These frames carry no event, but EventSource and the SDK's stream.lastEventId move 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 a Last-Event-ID header on reconnection) it carries on after that position.
  • It takes the same types, catalogues and payload parameters as the feed.
  • The server ends a stream after a while; reconnect with the last id (your access is checked again). The browser's EventSource cannot send a bearer token, so read it with fetch, or with the SDK's changes.stream, which reconnects for you.
  • Opening a stream is in the stream rate 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

TypeSent when
source.degradedA connection to a vendor's data
source.recoveredA connection to a vendor's data
source.blockedA connection to a vendor's data
product.createdA master product's change: what moved, and to what
product.updatedA master product's change: what moved, and to what
product.discontinuedA master product's change: what moved, and to what
product.publishedA master product's change: what moved, and to what
product.withdrawnA master product's change: what moved, and to what
product.mergedA master product merged into another: it now redirects to `merged_into`
product.unmergedA merged master product is its own again: every field, and the survivor it left
variant.createdA master variant's change: what moved, and to what
variant.updatedA master variant's change: what moved, and to what
variant.mergedA master variant merged into another: it now redirects to `merged_into`
variant.unmergedA merged master variant is its own again: every field, and the survivor it left
offer.createdA source's offer changed: price, stock or availability
offer.changedA source's offer changed: price, stock or availability
catalogue.verifiedWhether a vendor catalogue is verified by its brand
catalogue.unverifiedWhether 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 *):

TypeSent when
delivery.disabledFurniOps disabled one of your webhook endpoints: no delivery succeeded for 72 hours, or it answered 410 Gone. Sent to your other endpoints.
usage.threshold_reachedA meter reached 80% or 100% of its monthly quota, in live mode. See Usage and quotas.
export.completedA bulk export succeeded or failed. See Bulk exports.
proposal.createdAn AI agent proposed a change that passed its proof and waits for a person. See Proposals.
proposal.appliedA 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 by version.

On this page