Event order is not guaranteed — the version guard
Finish this page before you write the first line of switch (event.type)
Our retry window is ~92 hours ⇒ an event created on Monday can reach you on Thursday, after an event created on Wednesday.
No version guard = under_review overwrites settled = you release goods you should not, or hold back goods you should release.
We guarantee exactly three things
sequencealways increases, ordered by event creation time (not delivery time)data.object.versionincreases in the order the state actually changed in our database- 🔴 No two events for the same object ever carry the same
version— including events that do not change the status (attribute changes)
We do not guarantee that they arrive in that order, that they all arrive before the next one, or that none arrives twice.
Point 3 is what makes the pattern on this page safe: if UPDATE ... WHERE version < new returns rowCount = 0, that event is definitively older than what you already have. There is no other explanation.
Case A — expired arrives after settled (the most common one)
The user transfers late, the order has already expired, and then they upload the slip. We keep a 24-hour late-slip window open ⇒ an order that already went expired can still settle.
Time we created the event Time it reached your endpoint
10:04:41 deposit.expired v6 10:04:41 attempt 1 → endpoint down 3 min → fail
10:31:02 deposit.slip_received v7 10:31:02 ✓ 200
10:33:15 deposit.settled v9 10:33:15 ✓ 200 ← credited 5,000, released goods, correct
10:35:10 deposit.expired v6 ← attempt 4 only lands now!The code that breaks:
switch (event.type) {
case 'deposit.expired':
await cancelOrder(orderId); // 💥 cancels an order that already settled
break;
}What really happens: the user paid, got the goods, and your system marks the order canceled. Your totals no longer match the balance in your wallet with us ⇒ you find out at month-end reconciliation.
Case B — settled arrives after reversed (the most expensive one)
09:38:02 deposit.settled v9 attempt 1 → you answered 504 (slow DB)
09:41:20 deposit.reversed v12 attempt 1 → ✓ 200 · you correctly mark the order failed
09:44:12 deposit.settled v9 attempt 3 → ✓ 200 💥 credit + release goods, on an order that was already reversedWhat really happens: the goods have left the warehouse and the money has already been pulled back out of your wallet. There is nothing to recover.
How settled can lag reversed by six whole minutes
Attempt 3 comes 1 minute 10 seconds after the first, and attempt 4 comes 6 minutes 10 seconds after it — and in the meantime, an event created later succeeded on its very first attempt.
The flakier your endpoint, the more often you see this.
Case C — withdrawal.filled out of order
A 10,000 withdrawal is filled in 3 chunks:
chunk 1 4,000 filled_minor=400000 remaining_minor=600000 v7
chunk 2 3,000 filled_minor=700000 remaining_minor=300000 v9
chunk 3 3,000 filled_minor=1000000 remaining_minor=0 v11They reach you as v9 → v11 → v7. The code that breaks:
await setRemaining(w.id, event.data.object.remaining_minor); // 💥 ends up at 600000What really happens: the user's screen says 6,000 is still outstanding when the whole amount has been paid. If your system feeds that number into anything else, the user withdraws more than they should.
The correct approach — three principles
This is all you need to remember
- State comes from
data.object, not fromevent.type.event.typeonly says "something happened";data.object.statusis the truth as of event creation. - Guard on
versioninside a single SQL statement, not SELECT then if then UPDATE (two concurrent requests race each other). - Tie money/external side effects to the result of the UPDATE, not to the event type —
rowCount = 0means a stale event ⇒ no credit, reversal, or goods release; the sole exception is appending a previously unseen immutable overpayment flag.
Recommended table layout
-- 1) transactional inbox — the complete inbox/outbox schema is on the signature page
-- retain for at least 31 days because manual resend is available for 30 days
CREATE TABLE pg_webhook_inbox (
event_id text PRIMARY KEY,
event_type text NOT NULL,
event jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
CREATE INDEX ON pg_webhook_inbox (received_at);
-- 2) your correlation map — persist it from the 201/202 response; never depend on optional metadata
CREATE TABLE pg_deposit_correlations (
pg_deposit_id text PRIMARY KEY,
merchant_reference text NOT NULL UNIQUE,
our_order_id text NOT NULL,
our_user_id text NOT NULL
);
CREATE INDEX ON pg_deposit_correlations (our_order_id);
-- 3) your mirror of our deposit orders
CREATE TABLE pg_deposits (
pg_deposit_id text PRIMARY KEY, -- dep_... 🔴 text, not varchar(20)
pg_version bigint NOT NULL, -- ← the heart of the version guard
status text NOT NULL,
is_terminal boolean,
late_slip_accepted_until timestamptz,
ordered_amount_minor bigint,
transferred_amount_minor bigint, -- bank-verified transferred amount
amount_received_minor bigint, -- credit the end user with this one
fee_total_minor bigint,
credited_amount_minor bigint, -- the amount that lands in the merchant wallet
fee_breakdown jsonb, -- keep it raw for fee disputes
overpayment jsonb, -- latest immutable flag projection; fetch Claim for current state
balance_transaction_id text,
clearing_available_at timestamptz,
our_order_id text NOT NULL,
our_user_id text NOT NULL,
credited_user_at timestamptz, -- non-null = user already credited, blocks double credit
reversed_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pg_deposit_money_ck
CHECK (status NOT IN ('settled','reversed')
OR (ordered_amount_minor IS NOT NULL
AND transferred_amount_minor IS NOT NULL
AND amount_received_minor IS NOT NULL
AND fee_total_minor IS NOT NULL
AND credited_amount_minor IS NOT NULL
AND ordered_amount_minor > 0
AND transferred_amount_minor > 0
AND amount_received_minor > 0
AND amount_received_minor = LEAST(transferred_amount_minor,ordered_amount_minor)
AND fee_total_minor BETWEEN 0 AND amount_received_minor
AND amount_received_minor - fee_total_minor = credited_amount_minor))
);
CREATE INDEX ON pg_deposits (our_order_id);
-- 4) overpayment flags are append-only — pg_deposits.overpayment is only the latest projection
CREATE TABLE pg_deposit_overpayment_flags (
claim_id text PRIMARY KEY,
pg_deposit_id text NOT NULL REFERENCES pg_deposits(pg_deposit_id),
observed_deposit_version bigint NOT NULL,
cause text NOT NULL CHECK (cause IN ('same_transfer_excess','additional_transfer')),
amount_minor bigint NOT NULL CHECK (amount_minor > 0),
slip_received_at timestamptz NOT NULL,
status_snapshot text NOT NULL CHECK (status_snapshot = 'open'),
action_required_snapshot text NOT NULL CHECK (action_required_snapshot = 'contact_support'),
expected_response_at timestamptz,
raw_flag jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON pg_deposit_overpayment_flags (pg_deposit_id, observed_deposit_version);
CREATE FUNCTION pg_reject_overpayment_flag_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$ BEGIN
RAISE EXCEPTION 'pg_deposit_overpayment_flags is append-only' USING ERRCODE = '55000';
RETURN OLD; -- unreachable; makes the trigger return contract explicit
END $$;
CREATE TRIGGER pg_deposit_overpayment_flags_immutable
BEFORE UPDATE OR DELETE ON pg_deposit_overpayment_flags
FOR EACH ROW EXECUTE FUNCTION pg_reject_overpayment_flag_mutation();
-- 5) your mirror of our withdrawal orders
CREATE TABLE pg_withdrawals (
pg_withdrawal_id text PRIMARY KEY, -- wdr_...
pg_version bigint NOT NULL,
status text NOT NULL,
amount_total_minor bigint NOT NULL,
filled_minor bigint NOT NULL DEFAULT 0, -- 🔴 from the payload only, never SUM, never +=
remaining_minor bigint NOT NULL,
fee_total_minor bigint,
fee_recognized_minor bigint,
refunded_minor bigint,
settled_splits smallint,
our_payout_id text NOT NULL,
closed_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 6) fill rows — timeline/audit only 🔴 never drive business logic from these
CREATE TABLE pg_withdrawal_fills (
leg_ref text PRIMARY KEY, -- ← the chunk id, safe to dedupe on
pg_withdrawal_id text NOT NULL REFERENCES pg_withdrawals(pg_withdrawal_id),
sequence smallint,
amount_minor bigint NOT NULL,
fee_recognized_minor bigint,
settled_at timestamptz,
auto_confirm_at timestamptz,
balance_transaction_id text,
reversed_at timestamptz,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON pg_withdrawal_fills (pg_withdrawal_id, sequence);
-- 7) balance_transaction — immutable, no version ⇒ dedupe by id and accumulate
CREATE TABLE pg_balance_txns (
btxn_id text PRIMARY KEY,
type text NOT NULL,
direction text NOT NULL,
amount_minor bigint NOT NULL,
bucket_from text,
bucket_to text,
source_id text,
created_at timestamptz NOT NULL
);
CREATE INDEX ON pg_balance_txns (source_id);pg_deposit_id must be text
Our ids are not the short reference codes other gateways use. A varchar(20) column truncates them silently, and then you cannot reconcile.
And 🔴 never parse an id or infer meaning from its prefix — determine the type from event.type and data.object.object only.
Persist pg_deposit_correlations as soon as the create-deposit 201/202 response arrives, using the order/user mapping your system already owns. If a webhook wins that race, fail the transaction and return non-2xx so it is retried after the mapping is stored. Never fall back to metadata.user_id/metadata.order_id: metadata is optional and may be absent altogether.
Code — deposit side
async function applyDepositEvent(event) {
const o = event.data.object;
// objects without a version (balance_transaction) take a different path
if (o.version === undefined) return applyImmutable(event);
// version guard + database money + outbox + processed_at commit together
await db.tx(async (t) => {
const { rows: [correlation] } = await t.query(
`SELECT our_order_id, our_user_id FROM pg_deposit_correlations WHERE pg_deposit_id = $1`,
[o.id]);
if (!correlation) throw new Error('missing deposit correlation; retry webhook after create response is stored');
const { rowCount } = await t.query(`
INSERT INTO pg_deposits (pg_deposit_id, pg_version, status, is_terminal,
late_slip_accepted_until, ordered_amount_minor,
transferred_amount_minor, amount_received_minor,
fee_total_minor, credited_amount_minor,
fee_breakdown, overpayment, balance_transaction_id, clearing_available_at,
our_order_id, our_user_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (pg_deposit_id) DO UPDATE
SET pg_version = EXCLUDED.pg_version,
status = EXCLUDED.status,
is_terminal = EXCLUDED.is_terminal,
late_slip_accepted_until = EXCLUDED.late_slip_accepted_until,
ordered_amount_minor = EXCLUDED.ordered_amount_minor,
transferred_amount_minor = EXCLUDED.transferred_amount_minor,
amount_received_minor = EXCLUDED.amount_received_minor,
fee_total_minor = EXCLUDED.fee_total_minor,
credited_amount_minor = EXCLUDED.credited_amount_minor,
fee_breakdown = EXCLUDED.fee_breakdown,
overpayment = EXCLUDED.overpayment,
balance_transaction_id = EXCLUDED.balance_transaction_id,
clearing_available_at = EXCLUDED.clearing_available_at,
updated_at = now()
WHERE pg_deposits.pg_version < EXCLUDED.pg_version -- ← the whole point
`, [o.id, o.version, o.status, o.is_terminal ?? null,
o.late_slip_accepted_until ?? null, o.ordered_amount_minor ?? null,
o.transferred_amount_minor ?? null, o.amount_received_minor ?? null,
o.fee_total_minor ?? null, o.credited_amount_minor ?? null,
JSON.stringify(o.fee_breakdown ?? null), JSON.stringify(o.overpayment ?? null),
o.balance_transaction_id ?? null, o.clearing_available_at ?? null,
correlation.our_order_id, correlation.our_user_id]);
// Append before checking rowCount: a v11 event received after v12 must still preserve v11's claim/flag.
if (o.overpayment) {
const flagParams = [
o.overpayment.claim_id, o.id, o.version, o.overpayment.cause,
o.overpayment.amount_minor, o.overpayment.slip_received_at,
o.overpayment.status, o.overpayment.action_required,
o.overpayment.expected_response_at ?? null, JSON.stringify(o.overpayment),
];
const appended = await t.query(`
INSERT INTO pg_deposit_overpayment_flags
(claim_id, pg_deposit_id, observed_deposit_version, cause, amount_minor,
slip_received_at, status_snapshot, action_required_snapshot,
expected_response_at, raw_flag)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (claim_id) DO NOTHING
RETURNING claim_id
`, flagParams);
// The same claim can appear in a newer snapshot: retain the first-seen version,
// but never swallow a claim_id collision pointing at another deposit or payload.
if (appended.rowCount === 0) {
const duplicate = await t.query(`
SELECT 1 FROM pg_deposit_overpayment_flags
WHERE claim_id = $1
AND pg_deposit_id = $2
AND cause = $3
AND amount_minor = $4
AND slip_received_at = $5::timestamptz
AND status_snapshot = $6
AND action_required_snapshot = $7
AND expected_response_at IS NOT DISTINCT FROM $8::timestamptz
AND raw_flag = $9::jsonb
`, [o.overpayment.claim_id, o.id, o.overpayment.cause,
o.overpayment.amount_minor, o.overpayment.slip_received_at,
o.overpayment.status, o.overpayment.action_required,
o.overpayment.expected_response_at ?? null, JSON.stringify(o.overpayment)]);
if (duplicate.rowCount !== 1)
throw new Error('overpayment claim_id collision or immutable flag mismatch');
}
}
if (rowCount === 0) {
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now()
WHERE event_id = $1 AND processed_at IS NULL`, [event.id]);
return; // event is older than what we hold → drop silently
}
switch (o.status) {
case 'settled':
// 🔴 credit the end user with amount_received_minor, not credited_amount_minor
const credited = await t.query(
`UPDATE pg_deposits SET credited_user_at = now()
WHERE pg_deposit_id = $1 AND status = 'settled' AND credited_user_at IS NULL`,
[o.id]);
if (credited.rowCount === 1) {
await t.query(
`UPDATE users SET balance_minor = balance_minor + $1 WHERE id = $2`,
[o.amount_received_minor, correlation.our_user_id]);
await enqueueEffect(t, `deposit:${o.id}`, 'release_goods', {
order_id: correlation.our_order_id,
partial: o.partial === true,
received: o.amount_received_minor,
ordered: o.ordered_amount_minor,
});
}
break;
case 'reversed':
const reversed = await t.query(
`UPDATE pg_deposits SET reversed_at = now()
WHERE pg_deposit_id = $1 AND status = 'reversed'
AND credited_user_at IS NOT NULL AND reversed_at IS NULL
RETURNING amount_received_minor`, [o.id]);
if (reversed.rowCount === 1) {
await t.query(
`UPDATE users SET balance_minor = balance_minor - $1 WHERE id = $2`,
[reversed.rows[0].amount_received_minor, correlation.our_user_id]);
await enqueueEffect(t, `deposit:${o.id}`, 'hold_goods', {
order_id: correlation.our_order_id });
}
break;
case 'expired':
case 'canceled':
// 🔴 not the end if a late slip can still be accepted
if (o.is_terminal === false && o.late_slip_accepted_until)
await enqueueEffect(t, event.id, 'show_late_slip', {
order_id: correlation.our_order_id, until: o.late_slip_accepted_until });
else
await enqueueEffect(t, event.id, 'fail_order', {
order_id: correlation.our_order_id, reason: o.cancel_reason_code ?? 'expired' });
break;
case 'under_review':
await enqueueEffect(t, event.id, 'show_pending_review', {
order_id: correlation.our_order_id, message: o.review.reasons[0].display_th });
break;
case 'rejected':
await enqueueEffect(t, event.id, 'fail_order', {
order_id: correlation.our_order_id, reason: o.rejection?.display_th });
break;
default:
// 🔴 deposit status is a closed enum, but default still protects you when we add a value later
await enqueueEffect(t, event.id, 'update_order_status', {
order_id: correlation.our_order_id, status: o.status });
}
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now()
WHERE event_id = $1 AND processed_at IS NULL`, [event.id]);
});
}
display_this the Thai end-user copy we send with the event — render it as-is, since your end users are Thai.display_enis available alongside it for English-speaking users.
What this does to the three cases above:
| case | what happens |
|---|---|
| A | expired (v6) arrives while pg_version = 9 → WHERE 9 < 6 is false → rowCount = 0 → no side effect → answer 200 |
| B | settled (v9) arrives while pg_version = 12 → no second credit, no goods released |
| C | remaining_minor is only overwritten by a higher version → the amount is always right |
External effects must leave through a transactional outbox
The version guard blocks stale events, but not a worker crash halfway through. The version update, database balance change, outbox rows, and processed_at therefore have to commit in one transaction.
UPDATE pg_deposits
SET credited_user_at = now()
WHERE pg_deposit_id = $1
AND status = 'settled'
AND credited_user_at IS NULL;
-- only rowCount = 1 may increase the user's balance · do it in the same transaction as the increaseasync function enqueueEffect(t, dedupeScope, type, payload) {
await t.query(`
INSERT INTO pg_outbox (topic, dedupe_key, payload)
VALUES ($1, $2, $3::jsonb)
ON CONFLICT (dedupe_key) DO NOTHING
`, [type, `${dedupeScope}:${type}`, JSON.stringify(payload)]);
}Use a resource-bound scope such as deposit:${o.id} for one-shot business effects. Event-snapshot updates may use event.id. A later, higher-version event whose status is still settled therefore cannot release the goods again.
The outbox worker calls external systems such as goods release, email, and ERP. It sends dedupe_key as the idempotency key on every attempt. Never make a network call while the database transaction is open.
Code — withdrawal side
Two different things in the same delivery: the totals are overwritten only by a higher version, and the fill row is appended keyed on leg_ref.
async function applyWithdrawalEvent(event) {
const o = event.data.object;
if (o.version === undefined) return applyImmutable(event);
await db.tx(async (t) => {
// 1) totals — version guarded
const { rowCount } = await t.query(`
INSERT INTO pg_withdrawals (pg_withdrawal_id, pg_version, status,
amount_total_minor, filled_minor, remaining_minor,
fee_total_minor, fee_recognized_minor, refunded_minor,
settled_splits, our_payout_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
ON CONFLICT (pg_withdrawal_id) DO UPDATE
SET pg_version = EXCLUDED.pg_version, status = EXCLUDED.status,
filled_minor = EXCLUDED.filled_minor, -- 🔴 overwrite, never +=
remaining_minor = EXCLUDED.remaining_minor,
fee_recognized_minor = EXCLUDED.fee_recognized_minor,
refunded_minor = EXCLUDED.refunded_minor,
settled_splits = EXCLUDED.settled_splits,
updated_at = now()
WHERE pg_withdrawals.pg_version < EXCLUDED.pg_version
`, [o.id, o.version, o.status, o.amount_total_minor, o.filled_minor ?? 0,
o.remaining_minor, o.fee_total_minor ?? null, o.fee_recognized_minor ?? null,
o.refunded_minor ?? null, o.settled_splits ?? null, o.metadata.payout_id]);
// 2) fill row — idempotent on leg_ref · safe even when the totals are stale
// (rows are append-only and never conflict — unlike totals, which need the guard)
const fill = event.data.fill; // fill is a sibling of data.object, not inside the object
if (fill) {
await t.query(`
INSERT INTO pg_withdrawal_fills (leg_ref, pg_withdrawal_id, sequence, amount_minor,
fee_recognized_minor, settled_at, auto_confirm_at,
balance_transaction_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (leg_ref) DO NOTHING
`, [fill.leg_ref, o.id, fill.sequence, fill.amount_minor,
fill.fee_recognized_minor, fill.settled_at, fill.auto_confirm_at,
fill.balance_transaction_id]);
}
// 3) there is exactly one way to close the order — and it reads guard-approved values from your own DB
if (rowCount === 1 && o.status === 'settled' && o.remaining_minor === 0) {
const closed = await t.query(`UPDATE pg_withdrawals SET closed_at = now()
WHERE pg_withdrawal_id = $1 AND status = 'settled'
AND remaining_minor = 0 AND closed_at IS NULL`, [o.id]);
if (closed.rowCount === 1) {
await enqueueEffect(t, `withdrawal:${o.id}`, 'close_payout', {
payout_id: o.metadata.payout_id });
}
}
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now()
WHERE event_id = $1 AND processed_at IS NULL`, [event.id]);
});
}Three things you must never do on the withdrawal side
- 🔴 Never let
SUM(pg_withdrawal_fills.amount_minor)drive business logic. Some events may not have arrived yet, so you get a number that is too low and conclude "not fully paid" when it is. The fill table is for timeline and audit only. - 🔴 Never do
filled_minor += fill.amount_minor. A duplicate delivery adds twice. Overwrite with the payload value that passed the version guard, and nothing else. - 🔴 Never close the order on the first
withdrawal.filled. Close whenstatus = 'settled'andremaining_minor = 0.
Why fill rows need no version guard
Because they are new rows, not overwrites of an existing value. leg_ref is the primary key ⇒ ON CONFLICT DO NOTHING means a duplicate delivery cannot create a duplicate row.
Totals are different: they are "one value overwritten repeatedly", which is exactly where ordering matters.
Immutable objects — dedupe by id and accumulate
balance_transaction (btxn_*) has no version and no previous_attributes, because it is append-only.
async function applyImmutable(event) {
const b = event.data.object; // btxn_...
await db.tx(async (t) => {
await t.query(`
INSERT INTO pg_balance_txns (btxn_id, type, direction, amount_minor,
bucket_from, bucket_to, source_id, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (btxn_id) DO NOTHING
`, [b.id, b.type, b.direction, b.amount_minor, b.bucket_from, b.bucket_to,
b.source?.id ?? null, b.created_at]);
await t.query(`UPDATE pg_webhook_inbox SET processed_at = now()
WHERE event_id = $1 AND processed_at IS NULL`, [event.id]);
});
}Never use balance_after as your own running balance
balance_after is a snapshot from the moment that transaction happened. If events arrive out of order, overwriting with it gives you a stale number.
Use it for cross-checking only (compare against the total you computed yourself and alert on a mismatch). The real balance comes from GET /v1/pay/balance.
And remember that balance.cleared is a bucket move, not new money arriving — adding to the balance again at that point doubles it.
Gaps in sequence
if (event.sequence > maxSeen + 1) {
metrics.gauge('pg.webhook.gap', event.sequence - maxSeen - 1); // just alert
}
maxSeen = Math.max(maxSeen, event.sequence);A gap is not proof that something was lost
An event still inside its retry cycle can leave a gap open for up to 92 hours.
🔴 Never stop processing to wait for a missing event — you will stall permanently. Only a gap still open after 4 days means something is genuinely lost; fetch it with GET /v1/pay/events?since_sequence=.
Checklist for this page
- [ ] Every status write is an atomic UPDATE with
WHERE version < new, not SELECT-then-UPDATE - [ ] Every side effect is tied to the
rowCountof that UPDATE, not toevent.type - [ ] Version guard + business DB effect + outbox +
processed_atcommit in one transaction - [ ] External services are called only by an outbox worker with an idempotency key
- [ ]
rowCount = 0→ answer200and do nothing further - [ ] Crediting the user has a second guard on
credited_user_at IS NULLand usesamount_received_minor - [ ] Fill rows use
leg_refas the primary key plusON CONFLICT DO NOTHING - [ ]
filled_minor/remaining_minorcome from the guard-approved payload — noSUM, no+= - [ ]
balance_transactionis deduped onbtxn_id, andbalance_afteris not treated as the real balance - [ ]
expired/canceledcheckis_terminal+late_slip_accepted_untilbefore closing the order - [ ] Every
switchhas adefault:
Next
| read next | when |
|---|---|
| Testing before production | You are done writing — there is a test that delivers events out of order specifically for this. |
| Retry and endpoint outages | You want to know why an old event can be 92 hours late. |
| Event catalogue | You need to know which fields each event gives you to guard on. |
| Deposit statuses | You need to map statuses to end-user screens. |
| Receiving multiple fills | You are moving on to the withdrawal side. |
