Skip to content

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 ​

  1. sequence always increases, ordered by event creation time (not delivery time)
  2. data.object.version increases in the order the state actually changed in our database
  3. 🔴 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:

js
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 reversed

What 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       v11

They reach you as v9 → v11 → v7. The code that breaks:

js
await setRemaining(w.id, event.data.object.remaining_minor);   // 💥 ends up at 600000

What 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

  1. State comes from data.object, not from event.type. event.type only says "something happened"; data.object.status is the truth as of event creation.
  2. Guard on version inside a single SQL statement, not SELECT then if then UPDATE (two concurrent requests race each other).
  3. Tie money/external side effects to the result of the UPDATE, not to the event type — rowCount = 0 means a stale event ⇒ no credit, reversal, or goods release; the sole exception is appending a previously unseen immutable overpayment flag.
sql
-- 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 ​

js
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_th is the Thai end-user copy we send with the event — render it as-is, since your end users are Thai. display_en is available alongside it for English-speaking users.

What this does to the three cases above:

casewhat happens
Aexpired (v6) arrives while pg_version = 9 → WHERE 9 < 6 is false → rowCount = 0 → no side effect → answer 200
Bsettled (v9) arrives while pg_version = 12 → no second credit, no goods released
Cremaining_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.

sql
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 increase
js
async 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.

js
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 when status = 'settled' and remaining_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.

js
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 ​

js
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 rowCount of that UPDATE, not to event.type
  • [ ] Version guard + business DB effect + outbox + processed_at commit in one transaction
  • [ ] External services are called only by an outbox worker with an idempotency key
  • [ ] rowCount = 0 → answer 200 and do nothing further
  • [ ] Crediting the user has a second guard on credited_user_at IS NULL and uses amount_received_minor
  • [ ] Fill rows use leg_ref as the primary key plus ON CONFLICT DO NOTHING
  • [ ] filled_minor / remaining_minor come from the guard-approved payload — no SUM, no +=
  • [ ] balance_transaction is deduped on btxn_id, and balance_after is not treated as the real balance
  • [ ] expired / canceled check is_terminal + late_slip_accepted_until before closing the order
  • [ ] Every switch has a default:

Next ​

read nextwhen
Testing before productionYou are done writing — there is a test that delivers events out of order specifically for this.
Retry and endpoint outagesYou want to know why an old event can be 92 hours late.
Event catalogueYou need to know which fields each event gives you to guard on.
Deposit statusesYou need to map statuses to end-user screens.
Receiving multiple fillsYou are moving on to the withdrawal side.

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01