MerchantFlowMerchantFlow Docs
IntegrationsPublic API

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.

ParameterTypeNotes
updated_afterRFC 3339Records changed at or after this instant. Tracks financial changes, not just edits.
created_afterYYYY-MM-DDOrders placed on or after this date, resolved as start of day in the store's timezone.
created_beforeYYYY-MM-DDOrders placed on or before this date, end of day in the store's timezone.
statusdefault | allDefaults to revenue-bearing orders only. all adds cancelled orders.
sales_channeld2c | b2bStorefront or wholesale.
limitinteger1-250, default 100.
cursorstringFrom 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 * 100

allocated_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:5123456789012

That 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

StatusCodeMeaning
400invalid_parameterA query parameter was malformed.
400CURSOR_FILTER_MISMATCHA cursor was reused with different filters. Restart pagination.
400INVALID_CURSORMalformed or tampered cursor.
401missing_tokenNo Authorization: Bearer header.
401invalid_tokenExpired or invalid. Refresh.
401invalid_token_claimsThe token is not bound to a store. Re-run the authorization flow.
401grant_revokedThe merchant disconnected your app. Stop polling.
403insufficient_scopeToken lacks the required scope.
403client_not_authorizedApplication not provisioned or disabled.
404not_foundNo such record in this store.
405method_not_allowedThe API is read-only.
429rate_limitedSee Retry-After and X-RateLimit-Reason.
500internal_errorSomething failed on MerchantFlow's side. Retry with backoff.

Last updated: September 22, 2026

Last updated on

On this page