SALAF EMSDevelopers
salafems.com

Pagination

Cursor paging, the sync pattern, and why sorting is limited.

Lists are cursor-paginated. You ask for a page size and a position; you get rows and a cursor for the next page.

{
  "data": ["…25 objects…"],
  "meta": {
    "has_more": true,
    "next_cursor": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f"
  }
}

Parameters

ParameterTypeDefaultNotes
limitinteger25Maximum 100. Values outside the range are clamped, not rejected — limit=5000 gives you 100.
starting_afterobject idReturn the page after this object. Pass the previous page's next_cursor.
ending_beforeobject idReturn the page before this object. Paging backwards.
sortcreated_at or -created_at-created_atNewest first by default.

starting_after and ending_before are mutually exclusive — sending both is a VALIDATION_ERROR rather than a silently ignored parameter.

The meta block

FieldTypeMeaning
has_morebooleanThere is at least one more row after this page.
next_cursorstring or nullThe id to pass as starting_after for the next page. null on the last page.

next_cursor is deliberately null rather than the last row's id when the list is exhausted, so while (cursor) terminates on its own and nobody has to remember to check has_more as well.

Walking every page

Paste a key and every snippet on this page switches from the placeholder to your key — and the Try it panel under each endpoint is ready to send. It is stored in this browser only and goes nowhere except, if you press Send, straight to the API host you pick there.

# Page 1 — no cursor.
curl -sS "https://api.salafems.com/ext/v1/orders?limit=100" \
  -H "Authorization: Bearer $SALAF_API_KEY"

# The response ends with:
#   "meta": { "has_more": true, "next_cursor": "0f0b…c31" }
# Feed that cursor back as starting_after, and repeat until
# has_more is false.
curl -sS "https://api.salafems.com/ext/v1/orders?limit=100&starting_after=0f0b…c31" \
  -H "Authorization: Bearer $SALAF_API_KEY"

Why cursors instead of page numbers

Because the primary consumer of this API is a sync, and a store keeps taking orders while your sync runs.

With offset pages, one order placed while you are between page 1 and page 2 shifts every row down by one. Page 2 then re-serves a row you already had and skips one entirely — silently. The merchant discovers it weeks later as a missing order in their accounts.

A cursor is anchored to a row, not to a count. New rows arriving at the head do not move the rows behind it, so the walk stays correct no matter what the store does while you are reading. Deep pages are cheap for the same reason — there is no OFFSET 40000 for the database to count through.

Why you can only sort by created_at

This is the constraint people ask about most, so here is the actual reason.

A cursor works by remembering where you were in a sorted order. That only holds if the sort key does not move. created_at never changes after a row is written, so a cursor over it is stable for as long as you care to page.

updated_at moves constantly — every status change, every payment, every edit. Paging over it would mean a row you already read gets touched, jumps ahead of your cursor, and is served to you again while another row slips behind it and is never served at all. That is a data-loss bug wearing a feature's clothes, so the sort allow-list contains created_at and nothing else.

Syncing changes with updated_after

updated_after is the workhorse of any ongoing integration. It returns everything modified since an instant — including rows created long before it.

An order placed last month and confirmed this morning is precisely the row a created_after poll misses and an updated_after poll catches.

# Everything that changed since the last successful sync.
# Store the timestamp you STARTED the run at, not the one you finished at.
SINCE="2026-08-10T00:00:00Z"

curl -sS "https://api.salafems.com/ext/v1/orders?updated_after=$SINCE&limit=100&expand=items,customer" \
  -H "Authorization: Bearer $SALAF_API_KEY"

Three rules make an updated_after sync correct:

  1. Record the watermark before the run, not after. Anything modified while your run is in flight then gets picked up by the next run rather than falling into the gap between them.
  2. Upsert on id. The same order legitimately appears in many runs as it moves through its statuses. The newest snapshot always wins.
  3. Overlap slightly. Subtracting a minute from your stored watermark costs a few duplicate rows — which rule 2 already handles — and protects you from clock skew.

Webhooks let you stop polling frequently, but not stop polling: they are at-least-once and your receiver can be down, so a periodic updated_after pass stays the reconciliation path. Poll hourly or daily instead of every minute, and let events carry the urgency.

Filters are an explicit allow-list per resource, not a generic query language. That is a deliberate limit: a generic filter syntax would expose our internal schema as a public contract and freeze it there. The exact set for each endpoint is in the reference.

  • Ordersstatus, payment_status, fulfillment_status, channel (the slug, not the id), created_after, created_before, updated_after, search
  • Productsstatus, category_id, updated_after, search
  • Categories and Brandsstatus
  • Inventory levelswarehouse_id, variant_id, low_stock
  • Inventory movementswarehouse_id, variant_id

search matches order number, customer name and customer phone on orders; name and SKU on products. It is only offered where an index already supports it.

Orders are compact by default — related objects are referenced by id. Ask for them with expand:

curl -sS "https://api.salafems.com/ext/v1/orders?expand=customer,items&limit=50" \
  -H "Authorization: Bearer $SALAF_API_KEY"

expand accepts customer and items, comma-separated, one level deep — no nesting. It is opt-in rather than always-on because a sync walking ten thousand orders should not pay for line items it is going to throw away.

The last page

{
  "data": ["…7 objects…"],
  "meta": {
    "has_more": false,
    "next_cursor": null
  }
}

An empty list is data: [] with has_more: false — not a 404. A search that matches nothing is a successful request with no results.