A web store adds a second place where your players pay, and so a second set of books. Reconciliation is the daily proof that every paid order became exactly one grant, that every grant has a paid order behind it, and that every refund reversed both. It's dull, it's an hour to set up, and it's what turns "I paid and got nothing" from a crisis into a lookup.
The three records
| Record | Where it lives | Written by | Source of truth for |
|---|---|---|---|
| Order table | Your backend | Your order.paid / order.refunded webhook handler |
What you were told was paid |
| Store order list | Appside Studio → Orders | The store, when checkout completes | What the store says was paid |
| Wallet ledger | Your game's backend | Your grant code | What the player actually received |
All three carry the same order_id. That's the whole trick: one ID, three places, match on it.
The order table is the one most publishers don't have. Build it from the webhook: every order.paid inserts a row with order_id, player_id, sku, credits, price, currency, received time and the webhook event_id; every order.refunded updates the row's state and stores the refund time. Keep the raw webhook body next to the row; the auditor will ask. The webhook reference lists every field.
The daily three-way match
Run it once a day, for yesterday (UTC), automatically.
- Order table vs store order list. Export the store's orders for the day; compare by
order_id. Every store order should have a row; every row should have a store order. - Order table vs wallet ledger. Every paid row should have exactly one grant with the same
order_id,player_idandcredits. Every refunded row should have a grant and a reversal. - Totals. Sum of paid prices per currency, minus refunds, in the order table, equals the same sum in the store's list.
Three queries. If all three come back empty, nobody needs to look.
What each mismatch means
| Mismatch | Likely cause | Action |
|---|---|---|
| Store order, no order-table row | Webhook not received or rejected (bad signature, 5xx) | Check Studio → Integration → Health: last acknowledgement, failures in 24 h, dead letters. Appside retries with backoff for 72 h; fix the endpoint and the retry lands |
| Order-table row, no grant | Your handler acknowledged before granting, or the grant failed | Grant now, by order_id, inside the same transaction as the dedupe |
| Two grants, one order | Dedupe keyed on something other than order_id (e.g. event_id, which differs for the refund) |
Reverse one; fix the key |
| Grant, no order anywhere | Manual grant, a support tool, or a bug | Investigate; this is the row that becomes a fraud case |
| Refunded in store, still granted | order.refunded not handled |
Reverse the grant; handle the event (its event_id is <order_id>:refund) |
| Totals differ, rows match | Currency or price mismatch on one row | Compare price and currency field by field |
Design rules that make the match boring
- Acknowledge after the grant, not before. Return 2xx only when the grant is committed. If you return 2xx and then fail, no retry will come.
- One transaction for dedupe and grant. Insert the
order_idinto your processed set and credit the wallet in the same database transaction. Two separate writes are how you get one grant and two, depending on the day. - Key dedupe on
order_id, state ontype. The refund event has a differentevent_idbut the sameorder_id; it's an update to the row, not a new row. - Never delete. Refunded, declined and test orders stay, with a state. Reconciliation needs the full population.
- Keep the raw body and the headers. The signature, timestamp and body are the proof that the store said this. Storage is cheap; disputes aren't.
Refunds, retries and test orders
Refunds have their own page; for reconciliation the rule is that a refund is a state change plus a wallet reversal, both keyed on order_id.
Retries can arrive late. A delivery that failed on Monday can land on Wednesday, inside the 72-hour window; Wednesday's match will show a Monday order. Match on the order's paid time, not the time you received the webhook.
Test orders (mode: "test") flow through the same handler and the same match, which is the point: run the reconciliation on test data for a week before the store goes live, and the first live day has nothing new in it.
What to keep
For each order: the three rows, the raw webhook, your grant record, and any refund. For each day: the match results, including "nothing to report". That set answers every question a finance team, a payment provider or an auditor asks about web revenue, with the order_id as the thread.
Appcharge's framing of finance ops
Appcharge's post of June 4, 2025 lists what moving payments off the App Store adds to a finance team's plate: sales tax, chargebacks and support, and reconciliation across providers and currencies, and offers the choice between building on a PSP and using a merchant of record. Order reconciliation, the part above, is the piece that stays yours on any provider, because only your backend knows what was granted. It's one table, three queries and a daily job, and you can verify it with test orders before accepting player purchases.
Create your store free. No card needed to start; 3.9% per successful payment.