The symptom: right order count, wrong money #
You build a dashboard on top of the marketplace APIs. You pick a day, pick an account, and compare it against Seller Central. The order count matches exactly. The unit count matches exactly. The revenue is off by several hundred dollars.
If the counts match and the money doesn't, you do not have a sync bug. You have the right set of orders and the wrong definition of revenue. A missing-orders bug shows up as a wrong count; a definition bug shows up exactly like this.
That distinction is the single most useful thing to know when you start debugging, because it eliminates most of what you were about to check. Your pagination is fine. Your date window is probably fine. What's wrong is that you are summing a number that means something different from the number on the screen you're comparing against.
All figures in this post are illustrative. The patterns are from production reconciliation work across Amazon, Etsy, Walmart and eBay; the numbers are made up to demonstrate the arithmetic.
Mental model: a marketplace exposes at least three different "revenue" numbers per order — item principal, order total, and settled payout — and its own dashboards use different ones in different places. Matching means picking the pair that mean the same thing.
Which SP-API figure maps to which Seller Central figure #
This is the table people actually need. Amazon's Sales API getOrderMetrics is the number behind the Seller Central sales dashboard. The Orders API gives you something different, and getOrderItems gives you a third thing.
| Seller Central shows | Comes from | Includes | Your equivalent |
|---|---|---|---|
| Ordered product sales | getOrderMetrics.totalSales | Item principal only | Sum of line ItemPrice |
| Units ordered | getOrderMetrics.unitCount | — | Sum of line QuantityOrdered |
| Order total (order detail page) | getOrders.OrderTotal | Items + shipping + tax − promos | Your gross order value |
| Payout / settlement | Finances API | Net of fees, refunds, reserves | Neither of the above |
The most common mistake is comparing a dashboard headline built from OrderTotal against Seller Central's "ordered product sales". Those can never match. OrderTotal is gross — it carries shipping and tax. Ordered product sales is principal only. On a book with any shipping revenue at all, gross runs consistently higher, and you will chase that gap forever because it is not a bug.
The same day, computed three ways #
One illustrative account-day, with five USD orders and two in another currency. Every column below is a defensible number. Only one of them matches the Seller Central figure you are looking at.
| Method | What it sums | Result | Matches Seller Central? |
|---|---|---|---|
| A — naive gross | OrderTotal, all currencies added raw | $5,400 | No — double error |
| B — gross, FX-normalised | OrderTotal converted to USD | $4,850 | No — still includes shipping and tax |
| C — product sales, FX-normalised | Line ItemPrice converted to USD | $4,000 | Yes |
Method A is wrong twice over, and it is the one most first-version dashboards ship. It adds a foreign-currency amount into a USD total as though 1 CAD were 1 USD, and it compares gross against principal. The two errors push in the same direction, so the dashboard reads high and nobody can explain why.
The fix for the currency half is to normalise at write time, not at read time. Store the order's native amount, the FX rate for that order's own date, and the converted amount as three separate columns. Converting at query time with today's rate makes yesterday's report change overnight, which is worse than being wrong consistently.
Never sum mixed currencies without conversion. It is silent — no error, no null, just a total that is confidently wrong and drifts with your marketplace mix. If any account sells in more than one currency, assume this bug exists until you have proven otherwise.
Pending orders: the gap that only affects recent days #
Here's one that looks like a rounding issue and isn't. Amazon does not return prices for orders in Pending status — OrderTotal is absent and ItemPrice on each line is null. If you store what the API gives you, those orders are worth zero in your database.
Seller Central counts them anyway. "Ordered product sales" counts an order from the moment it is ordered, not when it ships or when payment clears. So every unpriced Pending order is worth full value to Amazon and nothing to you.
The visible effect is distinctive: your revenue is accurate for last month and understated for today and yesterday, because recent days are where the Pending share is highest. It converges on its own as orders move out of Pending, which is exactly why it survives so long — anyone who checks an old date confirms the dashboard is fine.
Two things make this worse than it needs to be. First, if your line-item repair job skips orders whose status is Pending — a very natural thing to write, since that's why they were unpriced — then you never go back for them. Second, Amazon prices some orders while they are still Pending. So "skip Pending" leaves money on the table that the API would happily hand you.
The rule that works: re-pull line items whenever the stored value is still zero, regardless of status, and stop as soon as a price lands. The "still zero" condition bounds the cost by itself.
List price is not the price paid #
The Amazon-shaped mistakes above have direct equivalents on every other marketplace, and they are worth knowing before you extend a dashboard to a second channel.
Etsy applies discounts at receipt level, not line level. A transaction's price is the list price. The receipt separately carries discount_amt, and a subtotal that is already net of it. Store price × quantity as your line revenue and you have recorded money the buyer never paid — on a shop running frequent sales, that can be a third of the line value. The order total is fine, because that comes from grandtotal; only per-product reporting is inflated, which means it hides until someone opens a Top Products view.
list price × qty = 200.00 ← what a naive sync stores
discount_amt = 90.00
subtotal = 110.00 ← what the buyer actually paid for goods
grandtotal = 117.00 ← + shipping + tax
The correction is to scale every line by subtotal / total_price and record each line's share of the discount separately, so the discount stays visible rather than being quietly absorbed into the price.
eBay's lineItemCost is a line total, not a unit price. Its own reference defines it as the single-unit price multiplied by the quantity. Treat it as a unit price and multiply by quantity again and a four-unit line records four times the real revenue. This one is invisible on single-unit orders, which are most of them, so it can sit undetected for a long time and only distort your multi-unit SKUs.
The general rule: for every money field you read, write down whether it is per-unit or per-line, and whether it is before or after discount. Those two questions cover most cross-marketplace revenue bugs. See also how the four marketplace APIs differ structurally.
A refund is not a cancellation #
This one destroys revenue rather than inflating it, so it is worth its own section.
Marketplace status vocabularies mix cancellation and refund into the same field. Etsy has a receipt status of Partially Refunded. Walmart has a line status of Refund. The tempting implementation is a substring test:
if "refund" in status.lower() or "cancel" in status.lower():
order.status = CANCELLED # wrong
"refund" in "Partially Refunded" is True. So a live, paid, partially-refunded order — one the buyer is still expecting — gets marked cancelled, drops out of the work queues, and disappears from revenue. Nobody ships it. The first signal is a customer complaint.
Cancelled and refunded are different states. Cancelled means the order never shipped and no money moved; it must leave the queues and must not count as revenue. Refunded means the order was real, usually shipped, and money went back afterwards; it stays in history, still counts toward gross, and gets flagged so net-of-refunds reporting can subtract it.
Two implementation notes that matter more than they look. Match on the exact status vocabulary each marketplace actually returns rather than on substrings. And put the decision in one module shared by every adapter — this exact bug appeared in two different marketplace adapters months apart in the same codebase, because each one re-derived the rule independently.
The reconciliation loop that tells you who's wrong #
Manual spot-checks don't scale past a couple of accounts, and they only tell you about the day you checked. What works is a scheduled comparison against the marketplace's own aggregate.
getOrderMetrics is the reconciliation tool for Amazon. One call per account per marketplace returns day-granularity order counts, unit counts and ordered product sales — the same figures behind the Seller Central dashboard. Anchor it to a reporting timezone with granularityTimeZone, because "a day" is a timezone-dependent concept and an unanchored comparison produces phantom mismatches at both ends of the range.
Etsy and Walmart have no equivalent metrics endpoint, so the independent source has to be a re-fetch of raw orders for the window, aggregated separately from your normal sync path. That is weaker — it shares a code path with the thing you're testing — but it still catches storage failures and status-mapping bugs.
Two design details decide whether this is useful or noise. Compare like against like — point the Amazon comparison at your item-principal sum, not your gross, or the tool flags every correct day and gets ignored within a week. And report per day, not as an average: "three days in the last fortnight disagree, here they are" is debuggable; "we're 4% off" is not.
When the check is wrong, not the data #
Worth saying plainly, because it costs real time: some of your mismatches will be bugs in the check. Three that recur:
- Gross compared against principal. Covered above. It flags every order that carries shipping, which is most of them.
- Discounts ignored on one side. If you validate that items + shipping + tax equals the order total, and you never captured
PromotionDiscount, every promotional order looks broken. The invariant needs the discount term: items + shipping + tax − discounts = total. - The marketplace disagreeing with itself. On a small number of orders,
getOrdersreturns anOrderTotalthat exceeds the sum of that same order'sgetOrderItemscomponents, with no extra charge field in either payload. Your data faithfully mirrors both. There is nothing to fix.
Also exclude the states where a null is the correct answer. Amazon returns no line items at all for Pending orders, and returns no total for certain zero-value orders such as free replacements. Flagging those produces alerts that are always present and always wrong.
An alert that cries wolf is worse than no alert. A check that fires nightly on data that turns out to be correct trains everyone to ignore the whole page — including the day it catches something real. Tune the check to zero false positives before you ask anyone to watch it.
The races that corrupt data quietly #
The last category isn't about definitions at all. If your sync runs several marketplaces concurrently, any check-then-insert is a race, and marketplace syncs are full of them.
The shape is always the same: SELECT to see whether a row exists, then INSERT if it doesn't, with no unique constraint underneath. Two overlapping sync runs both see "not there" and both insert. Common instances:
- Human-facing sequence numbers assigned with
SELECT max(n) + 1. Concurrent syncs read the same max and issue the same reference to different orders. - Product/catalogue caches keyed by product ID. A duplicate row is harmless until a reader uses a "exactly one or none" query and starts throwing.
- Order rows themselves, when the natural key isn't what you think it is — an Amazon order ID identifies a buyer's order, not a seller's, so two of your own accounts can legitimately receive the same one.
The fix is the same in every case, and it is not application-level locking: put a unique constraint on the natural key and let the database arbitrate. Then handle the resulting integrity error as a benign outcome — someone else won the race and the row you wanted exists.
try:
async with db.begin_nested():
db.add(CatalogItem(product_id=pid, marketplace=mp, ...))
except IntegrityError:
pass # another worker cached the same product; nothing to do
For a gap-free sequence, a counter row incremented with UPDATE ... RETURNING beats a database sequence: the row lock serialises the assigners, and because the increment lives in the caller's transaction, a rollback returns the number instead of leaking a gap.
A reconciliation checklist you can run #
Work down this list on a single account-day where you know the two numbers disagree. It isolates the cause in about fifteen minutes.
- Compare the counts first. Orders and units. If those match, stop looking for missing orders — it is a definition problem, and steps 3 onward apply. If they don't, it's a sync problem: check pagination, the date window, and whether you pull by created or last-updated date.
- Fix the timezone. Confirm both sides use the same definition of a day. Anchor your query and pass
granularityTimeZoneto the metrics call. - Name the two numbers. Write down exactly what each side sums. If one is gross and the other is principal, you have your answer already.
- Split by currency. Any non-USD orders in the window? Check that they are converted, and converted at the order's own date rather than today's.
- Count zero-value orders. How many orders in the window have zero item value, and what status are they in? Pending orders with no price are the usual recent-day gap.
- Check discounts. Does the marketplace discount at line level or order level, and are you capturing that field at all?
- Check quantity semantics. Per-unit or per-line? And make sure a legitimate zero quantity survives —
int(value or 1)turns a cancelled line's zero into a one, because zero is falsy. - Ask the marketplace directly. Call
getOrderMetricsfor that exact day and account. Whichever side disagrees with it is the side that's wrong — including, sometimes, yours.
Then automate steps 1–2 and 8 as a scheduled job, and keep the rest as the runbook for when it flags.
Running this across a lot of accounts? The per-account failure modes multiply — token expiry, rate limits and per-account egress all start mattering. I build and maintain multi-account SP-API integrations if you'd rather not own this.
FAQ #
Why does my dashboard match Seller Central for last month but not for today?
Almost always unpriced Pending orders. Amazon returns no OrderTotal and no ItemPrice for orders in Pending status, so they are worth zero in your database — but Seller Central counts them in ordered product sales from the moment they are ordered. Recent days have the highest share of Pending orders, and the gap closes by itself as they price, which is why old dates check out fine.
Which SP-API call gives the number Seller Central shows as "ordered product sales"?
getOrderMetrics in the Sales API, with granularity=Day. Its totalSales is item principal — no shipping, no tax. The equivalent in your own data is the sum of line ItemPrice from getOrderItems, not OrderTotal from getOrders.
Should I convert currencies at query time or when I store the order?
At write time. Store the native amount, the rate for the order's own date, and the converted amount as separate columns. Converting at read time with the current rate means a report for a past day changes every time you run it, which is harder to explain to a finance team than a fixed error.
Is a partially refunded order cancelled?
No. Cancelled means it never shipped and no money moved. Refunded means it happened and money went back afterwards — it keeps its revenue and its history, and gets flagged so net-of-refund reporting can subtract it. Matching status strings with a substring test conflates the two, and marks live orders as cancelled.
How often should reconciliation run?
Daily for a completeness sweep over a rolling window of about a week, and weekly for the aggregate comparison against marketplace metrics. Report per day rather than as a percentage — a flagged date can be investigated, an average cannot.
My reconciliation flags dozens of days. Where do I start?
Assume the tool is wrong first. If nearly every day flags, you are almost certainly comparing gross against principal, or ignoring a discount field on one side. A correct check should be quiet most of the time; if it isn't, fix the check before you touch the data.
Related guides
Amazon SP-API
The SP-API Reports Pattern Everyone Gets Wrong (Async, Step by Step)
The SP-API Reports API is asynchronous — request, poll, download, decompress — and most broken integrations either treat it like a synchronous call or poll it straight into a throttle. Here's the correct four-step flow, the status states that trip people up, and the gzip gotcha at the end.
Read guide →Amazon SP-API
Amazon Vendor Central API: What 1P Vendors Can Actually Automate
Vendor Central (1P) has a real API surface through the SP-API vendor family — retail analytics, purchase orders, shipments, invoices, and direct fulfillment. Here's what's genuinely automatable, what still isn't, and where the fastest wins are for a 1P vendor.
Read guide →Amazon SP-API
Amazon SP-API 403 Unauthorized: Every Cause and How to Fix It
A 403 "Access to requested resource is denied" from the SP-API almost never means your credentials are wrong — it usually means a role, a token, a region, or an authorization is mismatched. Here are the eight causes that produce a 403, in the order worth checking them, with the exact fix for each.
Read guide →Don't want to build this yourself?
I set this up for sellers and agencies every week. Book a free 30-minute audit of your Amazon data & PPC setup — you'll leave with a plan either way, whether we work together or not.