Change feed
A resumable feed of what changed. Poll GET /v1/events with a cursor, hold open GET /v1/events/stream, or receive the same events as webhooks. Each event carries an id and a type, never a row — you refetch through the normal read.
Everything else in this API answers what is true now. The change feed answers what changed, so a mirror of your freight can be kept current without re-reading the whole book.
GET /api/v1/events?limit=50
Authorization: Bearer <token>{
"object": "list",
"data": [
{
"object": "event",
"id": "aev_9c3f2b10-…",
"type": "shipment.updated",
"created": "2026-08-23T09:12:04.881Z",
"object_type": "shipment",
"object_id": "shp_1f2e…"
}
],
"has_more": false,
"next_cursor": "eyJ2IjoxLCJraW5kIjoiZXZlbnRzIiwic2VxIjoiOTE4In0.…"
}Requires the events:read scope. events:read opens the feed; it does not widen what is in it.
Each event also requires the read scope for the object it is about (shipments:read for
shipment.*, invoices:read for invoice.recorded, and so on — the same rule webhook
subscriptions follow). Events your credential cannot read are left out of the feed, not
refused: pages stay full, has_more stays accurate, and next_cursor moves past them. Asking
for one by ?type= returns no rows for it. The same applies to /v1/events/stream.
object_type is an open union
object_type may be shipment, vehicle, chain, match, quote, outreach or invoice.
The list grows: invoice was added on 2026-09-17. The field is not a constant, and a reader
that switches on it exhaustively needs a default case.
Ignore an object_type you do not handle; never throw on one. An event is a pointer, so an
event about an object your mirror does not track costs you nothing to drop. Treating an unknown
value as an error turns a future feature into an outage in your integration.
object_id is a prefixed id and its prefix follows object_type (shp_… for a shipment,
inv_… for an invoice). Parse the prefix rather than assuming it.
An unrecognised event still moves your cursor. Skipping an event you do not understand is
only half of the rule. You must also persist the cursor past it. A consumer that throws
before it saves next_cursor stops at the first unknown type and never receives anything
after it, including events it does understand. Each new type is recorded in
apps/web/lib/api/versions/.
An event carries an id and a type, never a row
You refetch through the resource's own read: GET /v1/shipments/{id} for a shipment, or
GET /v1/invoices/{id} for an invoice. This is deliberate. There is exactly one place that
decides what your organization may read, so the feed cannot become a second read path that gives
a different answer. It also means an event can never leak a field you are not scoped for. The
refetch checks your scopes. The event contains nothing to check.
An embedded row would also be a snapshot, possibly minutes or hours old by the time it reaches you.
Resuming
Persist next_cursor after every page and send it as ?after=. next_cursor is never null,
including on an empty page — a feed consumer must always have somewhere to resume from.
Do not order by created. It is when the change happened; the feed is ordered by an internal
sequence assigned at commit. The two can disagree by the length of one transaction, so sorting by
created reintroduces the gap the cursor exists to close. Process events in the order they arrive.
Ordering is guaranteed per object: two changes to the same shipment always arrive in the order they happened. Across different objects there is no ordering guarantee, and none is needed — an event tells you to refetch, and a refetch always returns the current state.
Retention, and what happens when you fall behind
Events are kept for 30 days. A cursor pointing before that window gets:
{ "error": { "type": "invalid_request_error", "code": "cursor_expired", … } }with status 410 Gone — not an empty page. An empty page would tell you that you are caught up
while your mirror had a hole. To recover, resynchronize from the resource list you mirror
(GET /v1/shipments, GET /v1/invoices, …). Then resume from the next_cursor of a fresh
GET /v1/events.
Event types
| Type | Meaning |
|---|---|
shipment.created | A shipment entered your book — newly created, or transferred to you from another organization. |
shipment.updated | Something a reader renders changed — status, availability, price, visibility. Routine re-checks that confirm rather than change a value do not emit. |
shipment.unshared | A shipment left your book. Drop it from your mirror; it will now 404. |
invoice.recorded | Your organization recorded a freight invoice that TOVR had not seen before. object_type is invoice; refetch through GET /v1/invoices/{id}. Requires invoices:read to see it in the feed, to refetch it, or to subscribe to it as a webhook. |
A transfer between organizations emits exactly two events, one to each side: the
organization that lost it gets shipment.unshared, and the one that gained it gets
shipment.created. It does not emit shipment.updated to either — a transfer is an
arrival and a departure, not a change, and one change should never produce two events that
say different things.
That means created and unshared together are a complete picture of what is in your
book, without subscribing to updated at all. Subscribe to updated when you also care
what a load says, not only whether it is yours.
shipment.unshared is the one you cannot afford to ignore. Without it a mirror keeps serving a
load you can no longer see — and stale freight is worse than missing freight, because it looks
current.
invoice.recorded
The event says recorded, not issued. TOVR does not issue freight invoices. The customer's ERP does, and the event means TOVR found out about one. It fires once per invoice, when the record is first inserted:
- A correction emits nothing. If the ERP re-sends the same
external_refwith a new amount or date, TOVR updates the record in place. There is noinvoice.updated. If you depend on an amount not changing, re-read the record. - Market freight emits nothing. An invoice against a public-market shipment belongs to no organization, so it has no audience.
- It names no shipment. An invoice can cover several transports. They are in
covers[]on the refetched record, not in the event.
This event was added on 2026-09-17. It is a MAJOR change for any consumer whose generated
client treats type or object_type as a closed enum, even if the consumer never reads
invoices. See
2026-09-17-invoice-events.
Filtering
?type=shipment.updated,shipment.unshared — a comma list, each member validated. An unrecognized
type is a 422 naming the valid set rather than an empty page.
Three ways to receive the same events
The feed, the stream and webhooks are one ledger delivered three ways. They carry identical payloads and honour the same cursor, so you can mix them — page through history with the feed, then subscribe from the cursor you stopped at.
| You get | Best when | |
|---|---|---|
GET /v1/events | A page, when you ask | Backfilling, or a batch job on a schedule |
GET /v1/events/stream | A push, as it happens | A live mirror you want current within seconds |
| Webhooks | A push, to a URL you host | You cannot hold an outbound connection |
Streaming
GET /api/v1/events/stream
Authorization: Bearer <token>
Accept: text/event-streamRequires the events:stream scope, which is separate from events:read and is not granted
automatically to a credential that has it. Holding a connection open is a different thing from
reading a page, so it is a different grant.
The response is Server-Sent Events. Each frame is named by its event type, so you can listen per type instead of switching inside one handler:
event: hello
id: eyJ2IjoxLCJraW5kIjoiZXZlbnRzIiwic2VxIjoiOTE4In0.…
retry: 3000
data: {"object":"hello","cursor":"eyJ2Ijox…","types":"all",
"minutes_limit":45000,"minutes_remaining":44987}
event: shipment.updated
id: eyJ2IjoxLCJraW5kIjoiZXZlbnRzIiwic2VxIjoiOTE5In0.…
data: {"object":"event","id":"aev_9c3f2b10-…","type":"shipment.updated",
"created":"2026-08-23T09:12:04.881Z","object_type":"shipment","object_id":"shp_1f2e…"}
: keepaliveA line beginning with : is a comment — the keepalive, every 25 seconds. It is never delivered
to onmessage, so it cannot be mistaken for an event.
Naming frames by type cuts both ways. addEventListener("shipment.updated", …) is the
convenient shape, and it means a NEW event type arrives under a name you never registered — so
you receive nothing and see no error. The catalogue is growing (object_type is already a
union; the types that fill it are next).
If your mirror must not miss a type it has not been told about, listen with onmessage as
well, or register a handler for every member of the published list and re-check it when you
upgrade. The same rule the section above gives for object_type applies here: ignore what
you do not handle; never assume the list is closed.
Connecting without a cursor starts you LIVE, not at the beginning. This is the one place the
stream differs from GET /v1/events, where omitting after starts at the oldest event.
A stream is a subscription. If you want history, page through GET /v1/events first and then
connect with the cursor you stopped at. The opening hello frame carries the current cursor so
that a first connection has somewhere to resume from before it has seen a single event —
persist it.
Resuming
Two ways, and after wins when both are present:
?after=<cursor>— anext_cursorfromGET /v1/events, or anidfrom a previous frame. They are the same value.- The
Last-Event-IDheader, which a standardEventSourcesends by itself on an automatic reconnect. You do not have to do anything for this to work.
Persist the id from event: cursor frames too. On a quiet connection you will see:
event: cursor
id: eyJ2IjoxLCJraW5kIjoiZXZlbnRzIiwic2VxIjoiOTQwIn0.…
data: {"object":"cursor","cursor":"eyJ2Ijox…"}It carries no event. The feed's position advances even when nothing in your account changed —
that is what stops a quiet account starving behind the 30-day retention sweep — and a stream
with no events has no frame to put the new position on. Ignore these and a connection held
through a quiet week reconnects into 410 cursor_expired.
Resume points are issued per batch, not per event — the id: field appears on the last frame
of each batch, the same granularity next_cursor is issued at. So a reconnect may redeliver
events from a batch that was cut off part-way through. It can never skip one. Treat delivery as
at-least-once and key on the event id.
When something goes wrong
Everything that can be decided before the connection opens arrives as an ordinary JSON error with
a request_id — 401, 403, 422, 410 cursor_expired, 429, 402. There is no such thing
as a stream that opens and then tells you it should not have.
Only the two conditions that can arise after connecting arrive as frames:
event: error
data: {"object":"error","code":"cursor_expired","message":"…"}cursor_expired means you fell behind the 30-day retention window while connected;
quota_exceeded means the plan's streaming minutes ran out. The stream closes after either.
It never silently jumps a gap — your mirror could not detect that, so a gap is always an error.
What a connection costs
Streaming is metered in connection-minutes: one when you connect, and one for each minute you stay connected. Not per frame, and not one request per connection — so a quiet month and a busy one cost the same, because the resource you are holding is the same.
When the plan's allowance runs out, open connections receive code: "quota_exceeded" and close,
and new ones are refused with 402. GET /v1/events is unaffected and stays available on the
ordinary request quota.
Your budget arrives in the hello frame as minutes_limit and minutes_remaining rather than
as response headers — headers are readable only at connect, and a frame can be re-sent. Both are
null when your plan's streaming is unmetered: absent, not zero.
Filtering
?type= takes the same comma list as the feed, validated the same way.
Invoices
Read the freight invoices your organization has recorded — GET /v1/invoices and GET /v1/invoices/{invoice_id}, scope invoices:read. TOVR records invoices your ERP issued; it does not issue them, so there is no PDF, no due date and no payment state.
Webhooks
Receive change-feed events as signed HTTPS POSTs. Manage endpoints from Settings → Developer or through this API. Deliveries are at-least-once and unordered — dedupe on the event id; the payload is a pointer, so order does not matter.