Public API Endpoint Reference - Orders, Products, Profitability
Complete reference for the MerchantFlow Public API: account, stores, orders with order-level economics, products with COGS, and profit and loss.
Endpoint reference
Base URL https://merchantflow.ai/api/v1. Every endpoint is GET and requires
Authorization: Bearer <access_token>.
The machine-readable contract is at /api/v1/openapi.json and is
unauthenticated, so you can generate a client before you hold any credentials.
GET /api/v1/account
Scope: store:read
The store this token reads, and whether its data is fresh. Use it as a health check and to confirm which store a stored token belongs to.
Returns store (id, name, platform, shop domain, currency, timezone),
connection (client id, granted scopes), data_availability (history_days,
available_from, last_sync_at, stale) and rate_limits.
GET /api/v1/stores
Scope: store:read
The stores this authorization covers. Today that is always exactly one - a merchant authorises one store per application - but it is returned as an array so multi-store authorization can be added later without breaking your integration.
GET /api/v1/orders
Scope: orders:read
The main endpoint. See Incremental sync for how to use it.
| Parameter | Type | Notes |
|---|---|---|
updated_after | RFC 3339 | Records changed at or after this instant. Tracks financial changes, not just edits. |
created_after | YYYY-MM-DD | Orders placed on or after this date, resolved as start of day in the store's timezone. |
created_before | YYYY-MM-DD | Orders placed on or before this date, end of day in the store's timezone. |
status | default | all | Defaults to revenue-bearing orders only. all adds cancelled orders. |
sales_channel | d2c | b2b | Storefront or wholesale. |
limit | integer | 1-250, default 100. |
cursor | string | From page.next_cursor. |
Date-only parameters resolve in the store's timezone, not UTC. That matters: for a merchant in Adelaide, a UTC-midnight boundary would clip roughly a day of orders off the edge of your range.
The default status filter
default means the same set of orders the merchant's own dashboard totals:
paid, completed, processing, partially_refunded and refunded, excluding
cancelled orders.
Refunded orders are included - their refunds amount is netted out of
net_revenue rather than the order disappearing. That is what makes your totals
reconcile with what the merchant sees.
Use status=all only for reconciliation, where you want cancelled orders too.
Order shape
{
"id": "ord_7c1e...",
"external_order_id": "5123456789012",
"external_order_gid": "gid://shopify/Order/5123456789012",
"order_number": "#1042",
"platform": "shopify",
"store_id": "ten_9f2b...",
"created_at": "2026-08-30T22:14:03Z",
"updated_at": "2026-09-01T06:02:11Z",
"canceled_at": null,
"refunded_at": null,
"currency": "AUD",
"financial_status": "paid",
"fulfillment_status": "fulfilled",
"sales_channel": "d2c",
"is_first_order": true,
"customer_order_sequence": 1,
"is_subscription_renewal": false,
"economics": {
"gross_revenue": 169.00,
"shipping_revenue": 12.00,
"discount": 20.00,
"tax": 18.90,
"tax_included_in_price": true,
"refunds": 0.00,
"net_revenue": 169.00,
"cogs": 62.40,
"cogs_source": "cogs_map",
"cogs_coverage_pct": 100,
"fulfillment_cost": 9.80,
"fulfillment_cost_source": "3pl",
"payment_fee": 5.62,
"ad_spend_allocated": 31.00,
"ad_spend_basis": "blended_daily_allocation",
"contribution_profit": 60.18,
"contribution_margin_pct": 35.61,
"allocated_overhead": 11.40,
"net_profit": 48.78,
"net_margin_pct": 28.86
},
"attribution": {
"primary_source": "meta",
"is_paid": true,
"utm_source": "facebook",
"utm_medium": "cpc",
"utm_campaign": "prospecting-au",
"utm_term": null,
"utm_content": "carousel-3",
"landing_page": "/products/trail-jacket",
"referrer": "https://l.facebook.com/",
"device_type": "mobile"
},
"shipping_country_code": "AU",
"customer_ref": "kQ7xR2mN8vTpL4wZ",
"line_items": [
{
"id": "li_3a9f...",
"sku": "TRJ-BLK-M",
"name": "Trail Jacket - Black / M",
"product_id": "prd_18c4...",
"variant_id": "var_9b21...",
"quantity": 1,
"price": 189.00,
"total": 169.00,
"cogs": 62.40,
"fulfillment_cost": 0,
"refund_amount": 0,
"refunded_quantity": 0
}
]
}Matching to your own data
external_order_id is the commerce platform's own order id. For Shopify that is
the numeric legacy id (5123456789012), which is what most Shopify APIs and
webhooks give you. If you work in Shopify GraphQL, external_order_gid is the
same order as a global id.
updated_at is the value updated_after compares against. Store it as your
watermark - it is not the same as a generic "last modified" timestamp.
Economics field notes
gross_revenue is the order subtotal after discounts, so discount is
reported for reference and is not subtracted again. net_revenue is
gross_revenue minus refunds. Shipping revenue and tax are reported
separately rather than folded in.
contribution_profit = net_revenue - cogs - ad_spend_allocated - payment_fee - fulfillment_cost
contribution_margin_pct = contribution_profit / net_revenue * 100
net_profit = contribution_profit - allocated_overhead
net_margin_pct = net_profit / net_revenue * 100allocated_overhead is the order's share of operating expenses. Both
percentages divide by net_revenue, and are 0 when it is zero or negative.
When the merchant treats sales tax as a cost (basis.tax_treated_as_cost on
the profitability summary), net_profit also has tax deducted.
total on a line item is post-discount revenue, and is legitimately 0 for a
fully discounted line such as a warranty replacement. Do not treat 0 as missing
and substitute price * quantity; that invents revenue.
ad_spend_allocated and cogs_coverage_pct both need care when displayed -
see Data and privacy.
GET /api/v1/orders/{id}
Scope: orders:read
One order, same shape as above. {id} accepts either the MerchantFlow id or
external:<platform order id>:
GET /api/v1/orders/external:5123456789012That means you never have to store MerchantFlow's ids if you already hold the platform's.
A 404 means no such order in this store. An order that exists in a different
merchant's store also returns 404, so ids cannot be probed. So does an order
older than the merchant's plan history window (see
Plan history windows).
GET /api/v1/products
Scope: products:read
Parameters: updated_after, status, limit (1-250), cursor.
Returns products with their variants and current base cost:
{
"id": "prd_18c4...",
"external_product_id": "8123456789012",
"name": "Trail Jacket",
"sku": "TRJ-BLK",
"price": 189.00,
"status": "active",
"cogs": {
"unit_cost": 58.00,
"handling_cost": 4.40,
"currency": "AUD",
"effective_from": "2026-07-01T00:00:00Z",
"source": "invoice_import",
"cost_is_tiered": false,
"cost_is_market_specific": false
},
"variants": [ /* same cogs shape per variant */ ]
}The example is abridged. Products also carry url, image_url,
compare_at_price, created_at and updated_at, and each variant its
external_variant_id, title, barcode, position and status - the
OpenAPI document lists every field.
cogs is null when no cost is recorded. null and 0 mean different
things - null is unknown cost, 0 is a genuinely free item. Treating unknown
as zero produces a 100% margin that is not real.
cost_is_tiered and cost_is_market_specific warn you that the single number
above is only part of the picture: this SKU also has quantity-tier or
per-market costs. For the cost actually applied to a specific sale, read the
order line's cogs - that one is resolved through the full pricing engine.
GET /api/v1/products/{id}
Scope: products:read
One product. Accepts the MerchantFlow id or external:<platform product id>.
GET /api/v1/profitability/summary
Scope: profitability:read
Parameters: start_date, end_date (YYYY-MM-DD, store timezone).
The full waterfall aggregated over the period:
{
"period": { "start": "2026-08-01", "end": "2026-08-31", "days": 31 },
"currency": "AUD",
"cogs_coverage_pct": 97,
"basis": {
"refund_recognition": "sale_date",
"tax_treated_as_cost": false,
"ad_spend": "blended_daily_allocation"
},
"totals": {
"gross_revenue": 184320.50,
"refunds": 6210.00,
"net_revenue": 178110.50,
"cogs": 61480.20,
"ad_spend": 38900.00,
"payment_fees": 5340.10,
"fulfillment_cost": 9820.00,
"product_margin_cm1": 121300.30,
"gross_profit_cm2": 116630.30,
"contribution_margin_cm3": 62570.20,
"ebitda": 41200.00,
"net_profit": 38150.40
}
}The example is abridged: totals also breaks out shipping_revenue,
discounts, tax, shipping_cost, variable_costs, opex,
amortised_expenses and interest_expense, plus the unit counts behind
cogs_coverage_pct. See the OpenAPI document for the full list.
Read basis before comparing these numbers to anything. It tells you three
things that change what the figures mean:
refund_recognition: "sale_date"- a refunded order's costs are recognised on the date the order was placed, matching its revenue. This is what makes the waterfall internally consistent.tax_treated_as_cost- follows the merchant's own setting, so these figures match their dashboard rather than a MerchantFlow default.ad_spend- always the blended daily allocation.
The three contribution margin levels follow the standard ecommerce waterfall: CM1 is net revenue minus product cost, CM2 minus full COGS, CM3 minus shipping, fulfilment, payment fees and ad spend.
GET /api/v1/profitability/daily
Scope: profitability:read
Parameters: start_date, end_date, limit (1-400, default 100), cursor.
The same fields as totals above, per day, oldest first. date is YYYY-MM-DD
in the store's timezone.
Re-fetch a trailing window rather than only new days - a past day's profit changes when the costs behind it change.
GET /api/v1/openapi.json
No authentication. OpenAPI 3.1 description of everything above.
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_parameter | A query parameter was malformed. |
| 400 | CURSOR_FILTER_MISMATCH | A cursor was reused with different filters. Restart pagination. |
| 400 | INVALID_CURSOR | Malformed or tampered cursor. |
| 401 | missing_token | No Authorization: Bearer header. |
| 401 | invalid_token | Expired or invalid. Refresh. |
| 401 | invalid_token_claims | The token is not bound to a store. Re-run the authorization flow. |
| 401 | grant_revoked | The merchant disconnected your app. Stop polling. |
| 403 | insufficient_scope | Token lacks the required scope. |
| 403 | client_not_authorized | Application not provisioned or disabled. |
| 404 | not_found | No such record in this store. |
| 405 | method_not_allowed | The API is read-only. |
| 429 | rate_limited | See Retry-After and X-RateLimit-Reason. |
| 500 | internal_error | Something failed on MerchantFlow's side. Retry with backoff. |
Last updated: September 22, 2026
Last updated on
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.
Data and Privacy - What the MerchantFlow API Shares and What It Does Not
What customer data the MerchantFlow Public API excludes, how the pseudonymous customer reference works, and how to present allocated ad spend and COGS coverage honestly.