Order changes (polling)
Follow every order change from every channel with a cursor — once a second, or long-polled, without missing one
Order changes
GET /api/v1/orders/changes is how an integration keeps up with orders. It returns every change to every order of your organization, from every source — delivery apps (HungerStation, Jahez, Keeta…), marketplaces (Trendyol, Amazon…), web stores (Salla, Zid, Shopify, WooCommerce…), external POS systems, Linkit's own till, payment orders and orders created through the API — in the order they were committed, each with a cursor you pass back to get the next ones.
It is built to be polled once a second: when nothing has changed, the answer is a 304 Not Modified served from memory, without a database read. If you would rather be told, use webhooks — both are fed by the same change log.
Every change is recorded by the database itself, so no source can bypass the feed — including connectors added after you integrate.
How to poll
- Start: call without a cursor. You get an empty page and the current
cursor. - Loop: call with
cursor=<the last cursor>and send the same value asIf-None-Match.304— nothing changed. Waitpoll_after_ms(1 s) and ask again.200— processchangesin order, keep the newcursor. Ifhas_moreistrue, ask again immediately.
- Persist the cursor after you have processed a page. Restarting from a saved cursor resumes exactly where you stopped.
GET /api/v1/orders/changes?cursor=djEuODc2MDEuMg
If-None-Match: "djEuODc2MDEuMg"Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | — | The cursor a previous response returned. Opaque: store it, do not parse it. |
since | RFC 3339 | — | Start at the first change at or after this time instead of at a cursor (retention: 30 days). May repeat a few earlier changes; never skips a later one. |
limit | integer | 100 | 1–500 changes per page. |
include | string | order | order: each change carries the order's current canonical state. raw: also the source's own payload. none: ids only. |
wait | integer | 0 | Long-poll: hold the request up to this many seconds (0–25) until a change arrives. |
Response
200 with headers ETag: "<cursor>" and X-Linkit-Cursor: <cursor>:
{
"spec_version": "2026-09-29",
"organization_id": "3xrde8yfgeqeaas",
"cursor": "djEuODc2MDIuMw",
"has_more": false,
"poll_after_ms": 1000,
"changes": [
{
"spec_version": "2026-09-29",
"id": "evt_87602_3",
"type": "order.status_changed",
"occurred_at": "2026-09-29T05:12:44.120331Z",
"organization_id": "3xrde8yfgeqeaas",
"cursor": "djEuODc2MDIuMw",
"order_id": "r1234567890abcd",
"change": "updated",
"changed_fields": ["order_status"],
"status_from": "received",
"status_to": "delivered",
"payment_status_from": "paid",
"payment_status_to": "paid",
"order": { "id": "r1234567890abcd", "status": "delivered", "channel": { "family": "hungerstation", "kind": "delivery" }, "...": "see the canonical order" }
}
]
}| Status | Meaning |
|---|---|
200 | A page (possibly empty). |
304 | Nothing after your cursor (If-None-Match matched). No body. |
400 | Malformed cursor, since, limit, include or wait. |
410 | Your cursor is older than retention (30 days). Resynchronise with GET /api/v1/orders and start again without a cursor. |
Event types
One change, one type — the most specific that applies:
type | When |
|---|---|
order.created | A new order arrived (any channel, or POST /api/v1/orders). |
order.status_changed | The order status changed. |
order.cancelled | The order became cancelled. |
order.refunded | The payment became refunded. |
order.updated | Anything else changed (changed_fields says what). A rewrite that changes nothing is not a change. |
order.invoiced | A platform order was invoiced at the till. |
order.deleted | The order was deleted (order is null). |
order is the order's state when the page is read, not at the moment of the change — two changes to one order on the same page carry the same current order. Use status_from / status_to for the transition.
Guarantees
- Nothing is skipped. Changes are ordered by commit, and a change whose writer has not finished yet is held back until it has, so a later cursor can never pass an earlier change.
- At-least-once. If you crash after processing a page and before saving the cursor, you will see those changes again. Deduplicate on
id. - Tenant-scoped. A cursor only ever reads the calling organization's changes; a branch-restricted user sees only their branch's.
Examples
# Start
curl -s "https://linkit.works/api/v1/orders/changes" -H "Authorization: Bearer $LINKIT_TOKEN"
# Poll (304 when nothing changed)
curl -s -i "https://linkit.works/api/v1/orders/changes?cursor=$CURSOR" \
-H "Authorization: Bearer $LINKIT_TOKEN" -H "If-None-Match: \"$CURSOR\""
# Long-poll up to 25 s
curl -s "https://linkit.works/api/v1/orders/changes?cursor=$CURSOR&wait=25" \
-H "Authorization: Bearer $LINKIT_TOKEN"let cursor = await loadCursor(); // from your storage; null the first time
for (;;) {
const url = new URL('https://linkit.works/api/v1/orders/changes');
if (cursor) url.searchParams.set('cursor', cursor);
url.searchParams.set('wait', '25'); // long-poll: returns as soon as something changes
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, ...(cursor && { 'If-None-Match': `"${cursor}"` }) },
});
if (res.status === 304) continue;
if (res.status === 410) { cursor = null; await resync(); continue; }
const page = await res.json();
for (const change of page.changes) await handle(change); // idempotent on change.id
cursor = page.cursor;
await saveCursor(cursor);
}import requests, time
cursor = load_cursor() # None the first time
while True:
params = {"wait": 25}
headers = {"Authorization": f"Bearer {token}"}
if cursor:
params["cursor"] = cursor
headers["If-None-Match"] = f'"{cursor}"'
r = requests.get("https://linkit.works/api/v1/orders/changes", params=params, headers=headers, timeout=35)
if r.status_code == 304:
continue
if r.status_code == 410:
cursor = None; resync(); continue
page = r.json()
for change in page["changes"]:
handle(change) # idempotent on change["id"]
cursor = page["cursor"]
save_cursor(cursor)
if not page["has_more"] and not page["changes"]:
time.sleep(page["poll_after_ms"] / 1000)Every Linkit SDK wraps this loop; each sends the cursor as If-None-Match and treats a 304 as "nothing new", not as an error:
| SDK | One page | Follow for ever |
|---|---|---|
| Go | client.Orders.Changes(ctx, opts) | OrderChangesPoller |
| Rust | client.orders().changes()…send() | client.orders().poller(cursor) |
| Python | await client.orders.changes(cursor) | async for e in client.orders.iter_changes(cursor) |
| TypeScript | client.orders.changesAsync({ cursor }) | for await (const e of client.orders.streamChanges({ cursor })) |
| .NET | client.Orders.GetChangesAsync(new() { Cursor = … }) | await foreach (var e in client.Orders.StreamChangesAsync(cursor)) |
| Kotlin | client.orders.changes(cursor = …) | client.orders.changesFlow(cursor) |
| Swift | try await client.orders.changes(cursor: …) | for try await e in client.orders.changesStream(cursor: …) |
| Dart | await client.orders.changes(cursor: …) | client.orders.changesStream(cursor: …) |
| C++ | client.orders().changes(request) | linkit::OrderChangesPoller |
| Zig | client.orders().changes(.{ .cursor = … }) | linkit.OrderChangesPoller |
The canonical order
Every order in the feed (and in webhooks, and in GET /api/v1/orders?format=canonical) has one shape whatever its source:
| Field | Description |
|---|---|
channel | {app_slug, app_name, family, kind, install_id, account, source} — kind is delivery, marketplace, ecommerce, pos, payments, api or unknown. A connector added later still resolves (kind: unknown, family from its slug). |
external | {order_id, reference, code, placed_at} — how the source knows the order. |
status / status_raw | Canonical received, ready_for_pickup, dispatched, delivered, cancelled, pending_payment, unknown — and the stored value verbatim. |
payment | {status, status_raw, method} — pending, paid, refunded, failed, cancelled, unknown. |
fulfillment | {method, method_raw, status, status_raw, branch_id, branch_reference, store_name, promised_for, is_preorder, receiver, shipping_address, dispatch_routing, shipping} — method delivery, pickup, shipping, dine_in, unknown. |
customer | {name, phone, email} |
amounts | {currency, subtotal, discounts, vat, delivery_fee, service_fee, total} |
lines | [{product_id, sku_id, name, quantity, unit_price, total}] — product_id is your product number as the source sent it. |
raw | The source's own payload, with include=raw only. |