← Back to product·Simulmedia VAMOS Docs·API reference / Events
API reference

Events

Everything that happens to an order is emitted as an event: pushed to your notify_url or queue as a webhook, replayable per order on demand, and resumable across your whole stream with the event feed. One tagged union of envelopes, both directions.

The order event envelope

Event shapes are a tagged union on event_type, with two fields common to every type: event_type and occurred_at. The exact field set per variant is defined once, in the canonical model (tech spec section 10 point 1); this table and the order event reference restate that one contract for the order event envelope, carried by order.status_changed alone. The standalone types carry their own envelopes, defined with the type table below, because an event about a catalog window or an ingest rejection has no order to identify: forcing order_id, seq, and status onto it would make the fields lies.

FieldTypeDescription
event_type requiredstringWhat happened. See event types.
order_id requiredstringPlatform order id.
external_order_id requiredstringThe buyer's idempotency key, echoed on every event for the order.
buyer_entitystringThe acting buyer, so a seller carrying several buyer organisations over one connection can attribute an event without a second lookup. Absent on an order booked before the field existed. See acting-buyer identity.
seq requiredintegerPer-order sequence number, strictly increasing. Retries reuse the same value.
status requiredenumOrder status after the transition. See order status.
prior_statusenumThe status the transition left. Platform-computed, never accepted from anyone, so it cannot disagree with the stream; optional only in that events recorded before the field existed replay without one. It exists so a consumer holding one delivery can route on the transition (a cancellation with prior_status before placed landed before the seller approved) without replaying the history to find out.
substatusstringFiner-grained progress marker; null unless the status carries one.
units[]array of objectPer-unit results when the transition carries them, and the transition's complete record of them: each entry carries unit_id, status (see unit status), num_accepted, reason, plus allocation (the {date, units} air dates, present when the transition decided them), and on a countered entry in_response_to (the unit_id the counter answers), counter ({week, unit_cost, num_offered}, the offer itself), and locked_rate_id (the quote commit the counter minted, per negotiation). The snapshot's line results are the fold of these entries, last write per unit, so nothing on a line exists that some event did not record.
artifactobjectAttached payload when the transition produced one: parsed_io, validation_result, plan_proposal, placement_receipt, or delivery_post. A placement_receipt records only its own facts, seller_order_id and placed_at; its daylocks rows are a projection, flattened from the same event's units[].allocation (ordered by unit_id, then date), never a second record of the air dates.
approvalobjectThe decision record, present on the transition a gate decision produced and absent otherwise: {gate, actor, decision, reason?}, where gate is seller_review on a seller's decide and needs_confirmation on a buyer's confirm, and decision is the derived outcome, not the request's headline. Defined once at approval; the seller view's approvals[] is the projection of these records.
creativesarray of object{unit_id, creative_id, creative_code?, name?} per line, present whenever any line references a creative; absent when none does. The code and the name are snapshotted, not read live. See the order event.
attachmentsarray of stringThe upload_id list, present only on the transition into seller_review of an order carrying attachments.
cancellationobjectRequired on every status: "canceled" event, absent otherwise: the identical cancellation block {reason, actor, late_notice, notice?} the response returns and the snapshot folds.
superseded_bystringOnly on the cancellation event a revision produces, naming the revision.
errorobjectStructured error on failed and rejected events; null otherwise. See Errors.
occurred_at requiredISO datetimeWhen the transition happened. UTC, like every timestamp on the platform.
{
  "event_type": "order.status_changed",
  "order_id": "ord_9f3a12c4b7e1",
  "external_order_id": "q4-campaign-042",
  "buyer_entity": "yourco",
  "seq": 5,
  "status": "placed",
  "prior_status": "seller_review",
  "substatus": null,
  "units": [
    {
      "unit_id": "u-0001",
      "status": "accepted",
      "num_accepted": 10,
      "reason": null,
      "allocation": [
        { "date": "2026-10-05", "units": 4 },
        { "date": "2026-10-07", "units": 3 },
        { "date": "2026-10-09", "units": 3 }
      ]
    },
    {
      "unit_id": "u-0002",
      "status": "accepted",
      "num_accepted": 10,
      "reason": null,
      "allocation": [
        { "date": "2026-10-06", "units": 10 }
      ]
    }
  ],
  "artifact": {
    "name": "placement_receipt",
    "seller_order_id": "ACME-77120",
    "daylocks": [
      {
        "unit_id": "u-0001",
        "date": "2026-10-05",
        "units": 4
      },
      {
        "unit_id": "u-0001",
        "date": "2026-10-07",
        "units": 3
      },
      {
        "unit_id": "u-0001",
        "date": "2026-10-09",
        "units": 3
      },
      {
        "unit_id": "u-0002",
        "date": "2026-10-06",
        "units": 10
      }
    ],
    "placed_at": "2026-10-02T14:41:09Z"
  },
  "approval": {
    "gate": "seller_review",
    "actor": "sales@acmebroadcasting.example",
    "decision": "approve"
  },
  "error": null,
  "occurred_at": "2026-10-02T14:41:09Z"
}
seq is per-order and strictly increasing, and retries reuse the same seq: dedup on (order_id, seq). Events are hints, not truth, and that is a statement about delivery, not about the record: the stored event log is the record of the order, and the snapshot is authoritative precisely because it is the platform's fold of that complete log, while any single delivery can be dropped, delayed, or replayed. If deliveries are missed, recovery is the order snapshot plus replay, or the event feed across every order at once, never guesswork.

Webhook deliveries are signed (X-Signature) and numbered (X-Delivery-Attempt); see verifying the signature.

Event types

Six types, one per genuinely distinct kind of happening: one lifecycle type, one order-linked notice, and four standalone types, each with the envelope its subject can truthfully fill.

One transition, one name. Every order lifecycle transition is order.status_changed, to both sides: an order entering seller_review is a single event with a single seq, and the buyer and the seller read the same type. The retired per-audience names (order.needs_review, negotiation.offer, order.canceled) were the same transitions viewed from the other role, each fully determined by the status the envelope already carries: a type name restating a field is vocabulary that is not earned. What you route on is status and prior_status, and what you subscribe to is a status filter, which is strictly more precise than the old names were.

Event typeSent toWhen
order.status_changedBuyer + Seller, per the eligibility matrix belowA status transition on an order, carrying status and prior_status. The old names map exactly: a queue arrival is status: "seller_review" (with the upload_id list when the order arrived with attachments); a buyer's negotiation response is status: "negotiating"; a cancellation is status: "canceled" with the cancellation block, and whether it landed before your approval is prior_status, a field rather than a type name
catalog.stale_rate_cardBuyerA rate card update changed lines on your in-flight orders
catalog.window_openedBuyer (subscribed)A discounted near-air window opened (remnant or opportunistic) with a signal payload
avails.answeredBuyerAn on-request avails answer landed; carries the avails_request_id to correlate with the poll resource. Body also reports declined or expired
catalog.integrity_failedSellerA catalog or rate-card update was rejected; conflict report attached
settlement.invoice_issuedBuyer + SellerAn invoice was generated on a cleared trade

These five non-lifecycle types stay types because they are not order transitions at all: nothing on the order's status machine moved, so there is no status for a filter to name. Each carries its own envelope beside the two common fields. catalog.stale_rate_card is an order-linked notice, not an order-stream event: its exact field set is the canonical model's: {event_type, order_id, external_order_id, buyer_entity?, rate_version, superseded_by, drift[], occurred_at}, dedup on (order_id, superseded_by), and it is absent from per-order replay, because nothing on the order's status machine moved and its recovery path is arithmetic rather than history: the drift is recomputable any time from the snapshot's locked prices against the live card. catalog.window_opened carries {seller, product_id, week, window, signal?}, dedup on (product_id, window.opens); catalog.integrity_failed carries {seller, commit or drop id, conflict report}, dedup on the rejected commit or drop id; settlement.invoice_issued carries {invoice_id, order_id, external_order_id}, dedup on invoice_id; and avails.answered keeps the envelope it always had ({avails_request_id, seller_id, state, occurred_at}, dedup on avails_request_id, absent from order replay). None of them carries seq or status: those are order-stream facts.

Who receives which transitions

The collapse renames types; it does not widen who hears what. Delivery and replay stay audience-projected from one stored log, exactly as before, and the eligibility is now stated as a matrix instead of being implied by type names. The one deliberate widening is named in the last row.

RoleReceives (and replays) order.status_changed for
BuyerEvery transition on its own orders, as always.
SellerTransitions into seller_review (the queue arrival the retired order.needs_review named); transitions into negotiating made by the buyer (the retired negotiation.offer; the seller's own counters are not delivered back to the seller); and transitions into canceled. Buyer-side intake traffic (submitted, validating, needs_confirmation, confirmed) is not seller-eligible, unchanged. The one exception is the row below.
Seller, briefsTransitions into validating carrying substatus: "awaiting_plan": the brief-first queue arrival. On a catalog order validating is intake the seller has no part in, but a brief parks there waiting for the seller's own plan, so that transition is the seller's queue arrival exactly as seller_review is on the catalog path. It is admitted on the substatus alone: a catalog order's validating carries substatus: null and stays invisible to the seller.
Seller, cancellationsAll cancellations, not only pre-approval ones: the retired order.canceled row was scoped to orders "you had not yet approved", but the notice window exists precisely so a seller hears about a late cancellation of a placed order (late_notice "is how it tells the seller there is something to decide"), which that scope contradicted. This is the one intentional widening, and prior_status is how a consumer tells the two apart.

Replay under each credential returns exactly that credential's eligible subset, so a replay never shows a side more than its deliveries did. Stored webhook registrations carrying retired type names keep working, migrated once at read time: order.needs_review reads as events: ["order.status_changed"], statuses: ["seller_review"], negotiation.offer as statuses: ["negotiating"], order.canceled as statuses: ["canceled"]. Role never travels in the filter: a registration belongs to a tenant whose role the platform knows, and eligibility applies before any filter does.

GET /v1/events: replay

GET/v1/eventsorders:write or orders:approve

Replay an order's event history: the recovery path when webhook deliveries are missed. The order snapshot carries last_seq; replay everything after your high-water mark and the loop is closed. Replay is per order; to resume your whole stream across every order at once, use the feed below. The order's seller may replay it too, which is the replay half of the same eventing that reaches both directions. Both sides read the same event_type: the whole envelope, seq, status, and prior_status included, is identical for both.

Query parameters

FieldTypeDescription
order requiredstringThe order_id to replay events for.
after_seqintegerReturn events with seq greater than this value; 0 replays the full history. Default: 0.

Response

Events in seq order, plus the order's current last_seq so you know when you are caught up. Replay re-shapes nothing. Each replayed item is the stored envelope with exactly two fields removed: order_id and external_order_id, hoisted once to the top of the response because every event in it belongs to the one order you named. No other field is projected away: the conditional payloads (units, artifact, approval, copy, attachments, cancellation, superseded_by, error) replay exactly as they were delivered, which is what makes replay a real recovery path rather than a summary of one. Compare the second event below against the webhook example above: it is the same event, differing only by the two hoisted identifiers. The only other selection is audience eligibility: it decides which events a credential replays, never what a replayed event contains.

200 Response

Timestamps in these examples are illustrative. The sandbox stamps every event, receipt, and as_of at the moment of the call, so a response you get back carries today's instant rather than the one printed here. What the examples fix is the shape and the ordering: stamps on one order are strictly increasing, and two calls a minute apart are a minute apart in the log.

{ "order_id": "ord_9f3a12c4b7e1", "external_order_id": "q4-campaign-042", "events": [ { "event_type": "order.status_changed", "buyer_entity": "yourco", "seq": 1, "status": "submitted", "prior_status": "draft", "substatus": null, "artifact": null, "error": null, "occurred_at": "2026-10-01T14:02:11Z" }, { "event_type": "order.status_changed", "buyer_entity": "yourco", "seq": 5, "status": "placed", "prior_status": "seller_review", "substatus": null, "units": [ { "unit_id": "u-0001", "status": "accepted", "num_accepted": 10, "reason": null, "allocation": [ { "date": "2026-10-05", "units": 4 }, { "date": "2026-10-07", "units": 3 }, { "date": "2026-10-09", "units": 3 } ] }, { "unit_id": "u-0002", "status": "accepted", "num_accepted": 10, "reason": null, "allocation": [ { "date": "2026-10-06", "units": 10 } ] } ], "artifact": { "name": "placement_receipt", "seller_order_id": "ACME-77120", "daylocks": [ { "unit_id": "u-0001", "date": "2026-10-05", "units": 4 }, { "unit_id": "u-0001", "date": "2026-10-07", "units": 3 }, { "unit_id": "u-0001", "date": "2026-10-09", "units": 3 }, { "unit_id": "u-0002", "date": "2026-10-06", "units": 10 } ], "placed_at": "2026-10-02T14:41:09Z" }, "approval": { "gate": "seller_review", "actor": "sales@acmebroadcasting.example", "decision": "approve" }, "error": null, "occurred_at": "2026-10-02T14:41:09Z" } ], "last_seq": 5 }

Errors

StatusCodeWhen
404NOT_FOUNDorder does not exist within your scope.
422INVALID_INPUTorder is missing, or after_seq is not a non-negative integer.

GET /v1/events/feed: the resumable stream

GET/v1/events/feedorders:write or orders:approve

Your whole event stream, resumable. Webhooks are push-only, so a subscriber that was down has no way to be caught up by them; per-order replay closes one order's gap but makes a subscriber with 500 live orders poll 500 histories. The feed is the third read on the same stored log: every event a webhook would deliver to your credential, in the order they were recorded for you, from wherever you left off. Keep the returned cursor beside your high-water marks and recovery after any outage is one loop.

The feed is delivery's pull twin, not an order history. It carries exactly what push delivery carries: all six event types, each in the same envelope a webhook delivers, with both order identifiers on lifecycle events (nothing is hoisted here, because the feed spans orders). So the standalone types that per-order replay excludes, catalog.stale_rate_card and avails.answered among them, do appear on the feed, exactly when a webhook would have delivered them: "absent from per-order replay" is a statement about an order's history, and the feed is not an order's history. Audience eligibility applies before anything else, as everywhere: each stream is its own eligible view of the platform's log and nothing more.

The stream and its cursor belong to your organization and role, never to your token. There is one stream per organization per role, because the eligibility matrix is role-shaped, and the request names which one it reads: the role parameter selects the buyer or the seller stream, and the token must hold that role's order scope (orders:write for the buyer stream, orders:approve for the seller stream, per the scope table). A token whose scopes admit only one role may omit role and gets that stream; a token holding both scopes must say which stream it is reading, because a scope set is a set of grants, not an instruction, and the first read of a stream has no cursor to disambiguate it. An organization on both sides of the marketplace therefore has two streams that never mix. A cursor names a position in one stream, so it survives token refresh and signing-key rotation: any credential resolving to the same organization and role with the right scope resumes exactly where the last one stopped. What a cursor never survives is a change of whose stream it is: presented against a different organization's stream, the other role's stream, or never issued at all, it answers the identical 422 INVALID_INPUT, indistinguishable from a malformed cursor, so a cursor can neither be borrowed across a tenant or role boundary nor probed for validity (the same fail-closed posture as the credit lookup). If your eligibility changes while a cursor is held, the cursor stays valid and reads from it simply apply current eligibility from that position forward: eligibility decides which events you get, never what a cursor is. Per-order replay follows the same scope rule: the order's buyer replays under orders:write, its seller under orders:approve.

Least privilege holds inside the stream. The role's order scope admits you to the stream and its order.status_changed items; the non-lifecycle types additionally require the scope that governs their domain, and a token lacking it simply does not receive them: settlement.invoice_issued requires settlement:read; catalog.integrity_failed requires catalog:write (the scope that published what failed); catalog.stale_rate_card, catalog.window_opened, and avails.answered require catalog:read. So an orders-only token reads an orders-only feed, and the feed under a full-scope token is exactly what push delivery carries, because webhook delivery is registered by the organization while a token speaks only for its own grants. Scope-skipped items behave exactly as filter-skipped items do: the returned cursor advances past them, and a later, broader token resuming from that cursor does not get them back.

Query parameters

FieldTypeDescription
roleenumbuyer or seller: which of your organization's streams this read addresses. Optional when the token's scopes admit only one role (that stream is the default); required when the token holds both orders:write and orders:approve, refused 422 INVALID_INPUT with details.field: "role" when omitted, because a scope set grants and never selects. Must match the stream a presented after cursor belongs to.
afterstringThe cursor a previous response returned; resumes immediately after it. Omit to start from the beginning of your retained stream. Opaque: store it and send it back, never parse or construct one.
eventsarray of stringFilter to specific event types, the same vocabulary the webhook registration takes. Omit for every type you are eligible for.
statusesarray of stringFilter order.status_changed items to transitions landing in these statuses, with exactly the webhook filter's semantics: narrows within eligibility, never widens past it, ignored for the non-lifecycle types.
limitintegerMaximum items per page, 1 to 500. Default: 100.

Response

Items in position order, plus the cursor to resume from and whether more is waiting. The feed re-shapes nothing, exactly as replay re-shapes nothing: each item is the stored envelope as delivery carries it, every conditional payload included.

200 Response
{
  "events": [
    {
      "event_type": "order.status_changed",
      "order_id": "ord_9f3a12c4b7e1",
      "external_order_id": "q4-campaign-042",
      "buyer_entity": "yourco",
      "seq": 1,
      "status": "submitted",
      "prior_status": "draft",
      "substatus": null,
      "artifact": null,
      "error": null,
      "occurred_at": "2026-10-01T14:02:11Z"
    },
    {
      "event_type": "avails.answered",
      "avails_request_id": "avr_7d21",
      "seller_id": "acme",
      "state": "answered",
      "occurred_at": "2026-10-01T15:40:02Z"
    }
  ],
  "cursor": "evc_000000000217",
  "has_more": false
}
FieldTypeDescription
events requiredarray of objectThe envelopes, verbatim. Dedup identities are unchanged: (order_id, seq) on order.status_changed, each standalone type's own key. The cursor is a position, never an identity.
cursor requiredstringThe position after the last item scanned, filtered items included, so a later call with different filters never re-delivers what an earlier filter skipped. Present on every response, an empty page included: a new subscriber that wants "from now on" pages once to the end and keeps the final cursor.
has_more requiredbooleanWhether events after cursor already exist. false means caught up as of this response, not that the stream ended: streams never end, so poll or lean on webhooks and resume on demand.

Positions are assigned by the commit, and the feed never reads past a gap. A position is minted by the same committed write that records the event itself: the platform appends every event, lifecycle and standalone alike, to your stream transactionally with the event store (the outbox posture ADR-0001 chose), and a page serves only the contiguous committed prefix of the stream. So two events committing concurrently can never cost you the earlier one: the feed will not serve position n+1, and your cursor will not pass it, while position n is still uncommitted. An event's position never changes once served, new events only append, and events recorded before your stream first materializes are backfilled into it once, ordered by a key every variant carries: occurred_at, then event_type, then the variant's own dedup identity rendered canonically (order_id:seq for order.status_changed; order_id:superseded_by for the drift notice; product_id:window.opens for windows; avails_request_id; the rejected commit or drop id; invoice_id). That key is total, because dedup identities are unique within a type, so ties on occurred_at break deterministically and a repeated backfill mints identical positions, never a duplicate or a skip. Within one order the feed preserves seq order; across orders it is commit order.

Retention, honestly. Until GA the platform retains streams in full, so 410 CURSOR_EXPIRED is reserved and never answered today; the post-GA retention horizon is an open decision (recorded in ADR-0018) and lands on the changelog before it ever takes effect. When it does, expiry loses the push-shaped copy of history, never the state: every event type's durable state has an authoritative pull surface. Order lifecycle recovers by the order-book sweep plus per-order replay; rate drift recomputes any time from the snapshot's locked prices against the live card; an avails answer stays readable on its request resource; invoices list on each side's own surface, the buyer's at GET /v1/settlement/invoices and the seller's at GET /v1/me/settlement/invoices, matching the event's buyer + seller delivery; a rejected catalog or card commit is re-checkable by re-publishing it (byte-identical publishes replay, and the same conflict report returns); and a discounted window is readable on the catalog and avails it opened. Recover what you need, then resume from a fresh feed read.

Errors

StatusCodeWhen
410CURSOR_EXPIREDafter predates your retained stream. Reserved: preview streams are retained in full, and a horizon lands on the changelog before this is ever answered. Recovery per type is in the callout above.
422INVALID_INPUTafter is not a cursor the addressed stream issued (another organization's cursor, or the other role's, answers this identically), role is omitted on a token holding both order scopes or names a role the token's scopes do not admit, limit is out of range, or a filter names an unknown type or status.