The retry is what refunds the customer twice.
A worker dies mid-batch, a webhook is delivered again, two agents open the same ticket: and the same broken order is resolved twice. Commerce Exception takes an order's payment, fulfilment, shipping and stock state, classifies every exception on it, and returns one action per exception with an idempotency key derived from the remedy rather than from the request. Replay the same exception and you get the same key. No proposed payout can exceed what was actually captured.
Runs the same engine the paid endpoint uses. Nothing is stored and no key is needed.
Press “Resolve this exception” to call the live API.
Three systems hold the truth. None of them agrees.
The processor knows what was captured. The WMS knows what shipped. The catalogue knows what is on the shelf. An exception is what happens in the gaps between them: and the gap is worked by hand, by whoever is on the queue that morning.
Nobody computes what is actually owed
Captured 394.00, delivered 19.00 of goods, one parcel lost, one capture duplicated. The number to refund is arithmetic across three systems, and it gets done in a spreadsheet, or in someone's head, under a ticket SLA.
Retries are indistinguishable from new problems
Queues retry. Webhooks redeliver. Two agents open the same ticket. Without a key that identifies the remedy, the second attempt looks like a fresh refund to the processor and it goes out.
The decision leaves no trace
A refund lands in the processor with a note field. Which rule fired, which evidence it rested on, what else was considered and rejected: none of it survives, so a chargeback six weeks later is defended from memory.
Every one of these is a silent, permanent loss.
Nothing errors. The refund succeeds, the ticket closes, and the money is gone. These are found at month end, if they are found at all.
-
Double refund
The same remedy applied twice
A batch failed halfway and was re-run. Both passes computed the same refund, and both were accepted, because the second call carried a key generated at request time rather than one derived from the order and the amount. The processor did exactly what it was asked.
-
Over-refund
More paid back than was ever taken
One order, several claims: a duplicate capture, a lost parcel, a return already in the warehouse. Each is real, each is handled by a different person or rule, and together they exceed the capture. Nothing in the stack is holding a ceiling.
-
Under-collection
Goods out of the door that were never billed
A partial shipment went out against a partial capture, the remainder shipped later, and nobody captured the difference. It is the same arithmetic as an over-refund with the sign flipped, and it is checked far less often.
A typed exception, a bounded action, a receipt.
Send the state your systems already hold. Back comes every exception on the order (not just the first) each with a severity, the money it puts at issue in integer minor units, and exactly one action to resolve it.
| Exception | Detected when | Decided action |
|---|---|---|
duplicate_charge | The same amount captured twice inside the duplicate window. | refund: bounded by what is actually overheld. |
payment_captured_not_fulfilled | Money taken, nothing shipped, fulfilment SLA passed. | reship if stock is proven, otherwise refund. |
shipment_lost_in_transit | Declared lost, or no carrier scan for the stale threshold. | reship if stock is proven, otherwise refund. |
return_received_not_refunded | Goods came back; the money did not follow. | refund of the returned value not yet returned. |
refund_owed_not_issued | Holding more than the goods kept are worth, with no specific cause. | refund of the residual balance. |
partial_shipment_unbilled_remainder | Goods worth more than was captured have shipped. | recapture, bounded by uncaptured authorization. |
inventory_oversold | More units unshipped than exist on hand. | escalate with the short units. |
return_not_restocked | Returned units are not back in sellable stock. | restock, in units rather than money. |
delivery_overdue | Past the promised date, still moving. | escalate. No money moves. |
Order state Ledger Findings Bounded actions Idempotency keys Receipt
-
01
One ledger, three systems
Ordered, shipped, delivered, returned and restocked units are valued at the line price, in integers. From those comes
entitledToHoldMinor: the value of goods that left, stayed gone and were not aboard a parcel that will never arrive. -
02
Every exception, not the first
An order can be charged twice and have a lost parcel and hold stock that was never booked back in. All three come back, each with its own severity and evidence. A queue needs the whole reason.
-
03
Claims allocated, not summed
Several findings can claim the same money. They are granted in priority order out of the overheld balance until it runs out: duplicate capture first, residual last. The strongest claim is paid in full and the weakest is the one clipped.
-
04
A refund can never exceed the capture
Everything payable is bounded by
capturedMinor − refundedMinor, at one named function. The engine asserts it again before returning: a plan that would move more money than was taken raises an error instead of reaching a processor. -
05
Keys derived from the remedy
The key hashes the order, exception type, action, currency, amount and subject. It does not hash the observation time, the ticket id or the request. Replay the same exception and you get the same key: which is the whole point of one.
-
06
A receipt you can store
What was decided, on what evidence, with a SHA-256 digest over the canonical decision: including the instant it was taken. A clean verdict gets one too, because "we looked and nothing was wrong" is worth being able to prove.
Charged twice, and the parcel never arrived.
$394.00 captured across two identical charges three minutes apart. A $178.00 keyboard shipment declared lost with no stock behind it; a $19.00 cable delivered. This is what POST /v1/exceptions returns, trimmed to the parts that decide the money.
{
"severity": "critical",
"ageDays": 19,
"ledger": {
"orderedValueMinor": 19700,
"inTransitValueMinor": 17800,
"entitledToHoldMinor": 1900,
"capturedMinor": 39400,
"netCapturedMinor": 39400,
"duplicateCaptureMinor": 19700,
"overholdMinor": 37500,
"refundCeilingMinor": 39400
},
"findings": [
{ "type": "duplicate_charge", "severity": "critical", "amountMinor": 19700 },
{ "type": "shipment_lost_in_transit", "severity": "critical", "amountMinor": 17800, "agedUp": true }
],
"actions": [
{ "kind": "refund", "amountMinor": 19700, "reason": "duplicate_capture_returned",
"idempotencyKey": "cxr_bdb5ff5fc060e3aa35f8c5d163170934" },
{ "kind": "refund", "amountMinor": 17800, "reason": "shipment_declared_lost",
"idempotencyKey": "cxr_c698a66459a3e8f94efdd57c2b3d4941" }
],
"totals": { "recoverableMinor": 37500, "refundMinor": 37500 },
"receipt": {
"moneyOutMinor": 37500,
"refundCeilingMinor": 39400,
"digest": "705a97fa4217025bce2b2e5426976ff3…"
}
}
$375.00, not $394.00
The cable was delivered and the customer kept it, so $19.00 of the capture was earned. The refund is the overheld balance, not the whole charge. Refunding the capture would have handed back money for goods the customer still has.
Two claims, one capture
The duplicate charge and the lost parcel both want refunding. They are granted in priority order out of the $375.00 that is actually overheld: so the two amounts sum to exactly that, and never to more.
Submit it again tomorrow
Different ticket id, different observedAt, different request: the same two keys. That is what makes a retry safe, and it is the property this API is built around rather than one it mentions.
This service is newly launched. We publish no uptime percentage, customer count, logo wall or certification claim until we have the operating history to support one. The API decides and returns a plan; executing it against your processor, WMS and ledger is your side of the integration.
Three steps to a queue that cannot double-pay
Post the order state
Lines with ordered, shipped, delivered and returned quantities; the payment; the shipment; optionally stock on hand. One unit per exception, up to 100 per call.
Execute the actions in order
They come back in execution order, which is also the order money was allocated. Pass each
idempotencyKeyto your processor as its idempotency key and the retry problem is gone.Store the receipt
One digest per decision, over the ledger, the findings, the actions and the instant. It is what you produce when someone asks in November why $375.00 went back in March.
Integer money, UTC days, bounded payouts
- Money is integer minor units. A fractional
unitPriceMinoris a400naming the exact field, not a rounding. Every value is whole units times a whole price, so nothing can be a cent out. - A refund never exceeds the capture. Bounded at one named function, then asserted again before the response is built.
- Currencies are never compared. A charge denominated in anything but the case currency is refused. There is no exchange rate here and there should not be.
- Deadlines are whole UTC days. A refund window that shifts an hour across a DST boundary is a window that closed a day early. Duplicate detection is the deliberate exception: it is minute-granular, because two identical captures in one day is ordinary and two in four minutes is a double-submit.
observedAtis required. Never defaulted to now, because every deadline is measured against it: so a replay months later reproduces the same verdict.- Nothing is stored. Every endpoint is stateless. You send the state with each request; no order, payment or customer data is retained here.
Small surface, versioned, OpenAPI-first
Authenticate with Authorization: Bearer <key>. Every error returns a stable code and a requestId you can quote, and validation failures name the exact field in details.path.
/v1/keysPublic. Emails a one-time claim token to the address you give it. No key is returned on this call, proving the mailbox is what stops anyone minting a key on your account./v1/keys/claimPublic. Spends the emailed token and returns the key, shown once: 250 exceptions a month, no card./v1/exceptionsResolve one exception or up to 100. Billed one unit per exception./v1/demo/resolvePublic. Real engine, capped at 10 lines and 10 charges. No key required./v1/exception-typesPublic. Every exception type, action kind, reason code and policy default.curl -X POST https://commerceexception-api.com/v1/exceptions \ -H "Authorization: Bearer $COMMERCE_EXCEPTION_KEY" \ -H "content-type: application/json" \ -d '{"exception":{"exceptionId":"case_88213","orderId":"SO-90417", "currency":"USD","placedAt":"2026-03-02","observedAt":"2026-03-21", "lines":[{"sku":"KB-88","orderedQty":2,"shippedQty":2, "deliveredQty":0,"returnedQty":0, "unitPriceMinor":8900,"onHandQty":0}], "payment":{"capturedMinor":17800,"refundedMinor":0}, "shipment":{"status":"lost","trackingId":"1Z-4471"}}}'
Python and TypeScript SDKs Service health: https://commerceexception-api.com/health
Priced per exception, capped so a backlog import can’t surprise you
One unit is one exception submitted for resolution, however many findings and actions it produces. Every plan includes an allowance, then a per-unit overage that is hard-capped per billing period: your worst-case bill is the base price plus the cap, never more.
- 10,000 exceptions included
- $0.06 per additional exception
- Overage capped at $600 per period
- Full API access, Python and TypeScript SDKs
- 50,000 exceptions included
- $0.04 per additional exception
- Overage capped at $2,000 per period
- Full API access, Python and TypeScript SDKs
- 250,000 exceptions included
- $0.02 per additional exception
- Overage capped at $5,000 per period
- Full API access, Python and TypeScript SDKs
- 1,000,000 exceptions included
- $0.01 per additional exception
- Overage capped at $10,000 per period
- Full API access, Python and TypeScript SDKs
Free tier: 250 exceptions per month, no card. Prices in USD, billed monthly. Payment is processed by Square through our shared billing platform; card details never touch this site.
What counts as an exception?
Does it move the money itself?
Why is the idempotency key derived rather than generated?
Can a plan ever refund more than was captured?
capturedMinor − refundedMinor at one named function, competing claims are allocated out of the overheld balance rather than summed, and the engine asserts the total again before returning. A plan that violated it would raise an error rather than reach a processor.What if I do not know my stock levels?
onHandQty off. Oversells then cannot be detected and a refund cannot be upgraded to a reship, and both are said plainly in warnings rather than guessed. Refunding money the ledger says you should not be holding is always defensible; picking stock that turns out not to exist is a second exception on top of the first.Can I stop it paying out large amounts automatically?
policy.autoRefundCapMinor. Above it the action becomes escalate carrying the proposed amount for a human to approve: and the money stays reserved meanwhile, so no other action on the same order can be granted it too.Can I evaluate it before paying?
Resolve it once, however many times you try.
The free tier is 250 exceptions a month, no card. Create a key, post one broken order, and submit it a second time: the keys come back identical.
One email address, no password, no card. Prefer the terminal? The same key, in two calls:
curl -X POST https://commerceexception-api.com/v1/keys \ -H "content-type: application/json" \ -d '{"email":"you@company.com","name":"Evaluation key","source":{"source":"web","medium":"landing"}}' # 202 Accepted. A one-time token is emailed to that address — spend it, # and the response carries the key, shown once. curl -X POST https://commerceexception-api.com/v1/keys/claim \ -H "content-type: application/json" \ -d '{"token":"<token from the email>"}'