DEV Community

Game2over
Game2over

Posted on Fully Autonomous

How "Get Paid to Play Games" Platforms Keep the Money Honest: An Insert-Only Ledger on Supabase

Search "get paid to play games" and you'll find hundreds of apps and sites promising cash for playing. From the outside they look like game portals. From the inside, they're payment systems with a game-shaped front end, and the database is the product.

I build Game2Over, an online earning platform. Users earn real rewards by playing partner games and completing tasks, offers and paid surveys (9,000+ opportunities through integrated partner networks and offerwalls), plus a set of free browser games we built ourselves, then cash out via PayPal, a Visa prepaid card or crypto. Once real money is involved, the database stops being a place you store state and becomes the thing you'll point at in every support dispute.

These are the design rules that held up, and why.

1. The ledger is insert-only

Every movement of value is a row in one transactions table: a reward, a reversal, a withdrawal, an admin adjustment. Rows are never updated to change an amount, and never deleted.

-- simplified for the article
create table z_dollar_transactions (
  id          bigint generated always as identity primary key,
  user_id     uuid not null references auth.users,
  type        text not null,  -- 'reward', 'reversal', 'withdrawal', 'admin_adjustment', ...
  amount_z    bigint not null check (amount_z > 0),
  status      text not null,
  created_at  timestamptz not null default now()
);
Enter fullscreen mode Exit fullscreen mode

Two consequences:

  • The balance is derived, never stored. A balance column would be a second source of truth, and sooner or later the two would disagree.
  • A correction is a new row. If support needs to fix something, it's a new admin_adjustment row with an audit-log entry, not an update. "Why is my balance X?" becomes a query instead of an argument.

check (amount_z > 0) looks odd at first. The direction lives in type, not in the sign, so no code path can write a credit that silently turns out to be negative.

2. Money moves only inside Postgres functions

The client never inserts into the ledger. Every flow (claim, postback credit, withdrawal request, cancel) is one security definer function, with execute revoked from public and granted explicitly:

revoke execute on function request_withdrawal(...) from public;
grant  execute on function request_withdrawal(...) to authenticated;
Enter fullscreen mode Exit fullscreen mode

That gives each flow one transaction, one place to enforce rules and one place to audit. It also keeps route handlers thin: authenticate, run the shared fraud precheck, call the RPC.

3. Assume every request arrives twice

A double-click, a mobile retry or a partner resending a postback: if the effect isn't idempotent, you pay twice. For withdrawals we stack three guards:

  1. pg_advisory_xact_lock(hashtextextended('withdrawal:' || user_id::text, 0)): one money operation per user at a time.
  2. A partial unique index: at most one pending or processing withdrawal per player.
  3. A client-generated client_request_id: a replay returns the original result.

Partner postbacks get the same treatment, keyed on the partner's transaction ID, and a partner reversal inserts a reversal row rather than touching the original.

4. A reservation is a ledger row too

When a player requests a withdrawal, a pending withdrawal row is written immediately, and the balance derivation already subtracts pending, processing and completed withdrawals. Rejecting or cancelling marks that reservation cancelled, which releases it. So a player can never spend the same balance twice while a person reviews the request.

5. Fraud checks live in one place

Earning platforms attract bots, multi-accounts and VPN farms, and partners claw back rewards from that traffic. Every claim route calls one shared precheck right after auth, and every claim RPC calls one shared fraud_guard() inside the transaction. A new earning feature can't forget the check, because the check is part of the pattern it copies.

6. Push, don't poll

Balances and activity feeds update from Supabase Realtime events on one signal bus, not from polling or page reloads. It's cheaper, and the UI never shows a balance older than the ledger.

7. Humans approve payouts

Automated payouts are tempting. We review every withdrawal by hand: queue oldest-first, copy the destination, record the tx hash or payment reference (the player sees it), with every action in an audit log. It's slower, but it's the reason honest players don't get rewards reversed weeks later.

What I'd tell anyone building something similar

  • Pick the source of truth on day one and make it impossible to bypass.
  • Make every write idempotent before you have users, not after your first double payout.
  • Put the rules in the database, and keep the API layer boring.

If you want to see how this looks from the player side, including how the paid surveys and offers are presented, have a look at Game2Over. Our own browser games run on Phaser 4 with a small shared framework, which I'll cover in a follow-up post.

Disclosure: I'm the developer of Game2Over.

Top comments (0)