MerchantFlowMerchantFlow Docs
IntegrationsPublic API

Incremental Sync and Pagination - MerchantFlow Public API

How to backfill and stay current with the MerchantFlow Public API using cursor pagination and updated_after, including why order economics change after an order is placed.

Incremental sync and pagination

This is the page to read carefully. Getting sync right is most of the work in a MerchantFlow integration, and the part that most often goes subtly wrong.

The thing that makes this different

An order's economics change after it is placed. This is not an edge case; it is the normal life of an order in MerchantFlow:

  • The merchant corrects a product's cost, so the order's COGS and profit change.
  • A 3PL reports the actual fulfilment cost three days after shipping.
  • Ad spend syncs and gets allocated across the day's orders.
  • Attribution is enriched, so the order's UTM data changes.
  • A refund lands.

If you sync on "orders created since yesterday", you will capture each order once with whatever numbers happened to exist that morning, and never see any of the above. Your profit figures will drift away from the merchant's dashboard and you will not be able to tell why.

updated_after exists precisely for this. It tracks a change marker that advances whenever anything you can read about an order changes - including every case above.

Backfilling

Page with cursors until page.has_more is false.

curl "https://merchantflow.ai/api/v1/orders?limit=250" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
{
  "success": true,
  "data": [ /* up to 250 orders */ ],
  "page": {
    "has_more": true,
    "next_cursor": "eyJ2IjoxLCJrIjoiZGF0YVZlcnNpb25BdCIsInQiOiIyMDI2...",
    "limit": 250
  }
}

Then:

curl "https://merchantflow.ai/api/v1/orders?limit=250&cursor=eyJ2IjoxLCJrIjoi..." \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Repeat every filter on every page. A cursor is bound to the exact filter set it was created with. Sending it with different filters returns 400 CURSOR_FILTER_MISMATCH rather than silently giving you a page computed against the wrong query. That error is a guardrail, not a bug - it is telling you your pagination loop dropped a parameter.

Cursors are opaque. Do not parse, construct, or store them long-term; they are for the duration of one pagination run.

Staying current

Record the updated_at of the last order you successfully processed. On the next run:

curl "https://merchantflow.ai/api/v1/orders?updated_after=2026-09-02T23:55:00Z&limit=250" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Results come back oldest change first. That ordering is deliberate: if your sync dies halfway, the watermark from the last successfully processed record is always safe to resume from - you can never skip a record by crashing.

Two rules that matter

Resume from your watermark minus a few minutes. Five is a reasonable default. MerchantFlow writes concurrently from several workers, so change markers are not perfectly monotonic across processes. A small overlap costs you a handful of duplicate records and protects you from missing one.

Treat the feed as at-least-once. You will occasionally receive an order you have already seen, unchanged. Upsert on id (or external_order_id) rather than inserting, and this is a non-event.

A minimal loop

watermark = load_watermark()  # ISO 8601, or None on first run

params = {"limit": 250}
if watermark:
    params["updated_after"] = shift_back(watermark, minutes=5)

cursor = None
while True:
    if cursor:
        params["cursor"] = cursor
    body = get("/api/v1/orders", params).json()

    for order in body["data"]:
        upsert(order)                       # idempotent on order["id"]
        watermark = order["updated_at"]     # oldest-first, so this only moves forward

    if not body["page"]["has_more"]:
        break
    cursor = body["page"]["next_cursor"]

save_watermark(watermark)

Save the watermark only after the page is fully processed. If you save it per record and then crash mid-page, you are still correct - that is the point of oldest-first ordering.

Products and profitability

GET /api/v1/products supports updated_after the same way.

GET /api/v1/profitability/daily is paginated by date rather than by change marker. Re-fetch a trailing window - the last 30 to 60 days is usually right - rather than only fetching new days, because a day's profit changes when costs behind it change.

Deletions

updated_after cannot express a deletion, and MerchantFlow does not have a deletion feed.

In practice orders are cancelled, not deleted. A cancellation does advance the order's change marker, but under the default status filter the cancelled order is excluded from the results - so a client polling with the default filter never receives the record with canceled_at populated. The order simply stops appearing.

If cancellations need to propagate to your side, poll updated_after with status=all (and repeat that filter on every cursor page). The cancelled order then comes back with canceled_at set, and you can mark it accordingly. If you stay on the default filter, treat the periodic reconciliation below as the only way you learn about cancellations.

Rows genuinely can disappear - a merchant closing their account, or a full data resync rebuilding history. Those will not appear in your incremental feed. If exactness matters to you, run a full reconciliation periodically (monthly is usually enough): page the whole order list without updated_after and mark anything absent as gone on your side.

Plan history windows

A merchant's plan bounds how far back they - and therefore you - can read.

PlanHistory
Starter90 days
Pro365 days
PlusUnlimited

This is not a restriction on partner applications specifically; it is the same window the merchant sees in their own dashboard. Showing them data they cannot see themselves would be incoherent.

Two response fields tell you where you stand:

  • meta.history_available_from - the earliest date included, or null for unlimited
  • meta.truncated - true when the plan window cut off part of the range you asked for. On Starter and Pro this is also true whenever you omit created_after, because an open-ended request always reaches past the window

truncated: true is not an error. Everything from history_available_from onwards is present and complete. If you are showing a merchant a chart that starts abruptly, this is why, and saying so is better than leaving them to guess.

Rate limits

Per store, per application:

LimitValue
Burst60 requests/minute
Hourly1,000 requests
Daily10,000 requests
Concurrent2 requests in flight

And per application across all connected stores: 200 requests/minute.

A 100,000-order backfill at 250 per page is roughly 400 requests, so it fits comfortably inside an hour. A nightly incremental sync for one store is usually single digits.

Two of these catch specific mistakes:

Concurrency (2). Page cursors sequentially. Firing many parallel requests at one store will hit this immediately, and parallelism does not help anyway - a cursor page depends on the one before it.

Per-application global (200/min). If you connect 500 merchants and start every sync at 02:00, you will hit this even though each individual store is well within budget. Stagger your schedule - jitter the start time per connection.

On 429 you get Retry-After (seconds) and X-RateLimit-Reason, one of burst, hour, day, concurrency, client_global. Back off on the named limit rather than retrying blindly. Successful responses carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Policy for the hourly budget, so you can pace yourself before hitting a wall.

Caching

/api/v1/account, /api/v1/stores and /api/v1/profitability/summary return Cache-Control. Honour it - these are the endpoints most likely to be polled and least likely to change, and respecting the header is free headroom against your budget.

Next

Last updated: September 22, 2026

Last updated on

On this page