Gifting
The gift spend transaction -burn order, diamond credit, idempotency.
Last updated Thu Jul 16 2026 00:00:00 GMT+0000 (Coordinated Universal Time)
TL;DR -When a viewer taps a gift, coins leave their wallet, diamonds land in the host's, and the room plays an animation -all inside one all-or-nothing database transaction that can't be double-charged.
Who this is for -Product & Business: what a gift does and how the earnings split works (the first two sections). Engineers: the full transaction flow, the runtime-tunable policy, and the gift catalog below.
Gifting is the economy's engine. It's the moment where everything connects: a viewer spends coins (the currency bought with real money), a host accrues diamonds (the currency they cash out), and the room sees a celebratory animation. Get this one transaction right and the whole money model holds together.
In plain terms: what a gift does
A viewer taps a gift in the tray. In one indivisible step, the app checks they can afford it, takes the coins, works out how many diamonds the gift is worth, and hands those diamonds to the right person. Because it's a single transaction, a dropped connection or a double-tap can never charge twice or pay out twice.
Two rules shape who gets what:
- Free coins are spent first. If a viewer has both bonus (free) coins and paid coins, the free ones burn first -and only the paid portion earns the host any diamonds. Gifting with promo coins is fun for the viewer but pays the host nothing.
- You can gift a guest, not just the host. Normally a gift goes to the room host. But if a guest is up on stage, a viewer can gift them directly. When that happens the earnings are split (details below). You can never gift yourself, in any combination.
How it works: the spend transaction
The plain-English flow above maps to a single endpoint. Here is the exact call:
POST /wallet/gifts/send { roomId, giftId, recipientId?, idempotencyKey },
which runs entirely inside one Prisma $transaction. The sequence diagram
traces every step from the tap to the updated balances.
Rendering diagram…
Rules encoded in that flow
Each rule below is one of the guardrails visible in the diagram.
- Idempotency: the client generates a fresh UUID per tap; the backend
key is
gift_send:<userId>:<uuid>. Duplicate taps and network retries replay the original result instead of double-spending. - Burn order: bonus coins first, paid coins second. Only the paid portion generates host diamonds.
- Recipient: defaults to the room host; may target a stage guest. Self-gifting rejected in every combination.
- Guest-gift split: when the recipient is not the room host, the
diamond credit is split -recipient 70%, room host 25%, remaining 5%
platform margin (never issued). Host-targeted gifts keep the full
credit. Each credited party gets its own
DIAMOND_EARNINGledger row and settlement, both on the standard 72h hold. When the host gifts a guest, the guest still earns 70% but the host's 25% cut is skipped — a sender never earns diamonds from their own spend.
How it works: runtime-configurable policy
Some of the numbers above aren't baked in -an admin can retune the guest split and the settlement hold live, without shipping a new build. This is how a policy change takes effect within seconds while never disturbing money that already moved.
The guest split shares and the settlement hold are admin-tunable at
runtime -no deploy needed. The 70/25/5 and 72h figures above are the
compiled defaults in economy.constants.ts; overrides live in the
app_settings row economy_gifting_policy and win when present.
- Edit under Admin portal → Settings → Gifting policy, or via
PUT /admin/settings/gifting-policy{ guestGiftRecipientShareBasisPoints, guestGiftHostShareBasisPoints, settlementHoldHours }(shares in basis points; combined shares must not exceed 10,000; hold 0–720 hours). Current values come back fromGET /admin/settings. EconomyPolicyServicecaches reads for ~10s, so a change applies to new gifts within seconds.- Changes are forward-only: every ledger row snapshots the shares and hold in effect when it was written, so historical financials and already scheduled settlements never move.
- Rate snapshots: each ledger row stores the rates in effect
(
coinsPerDiamond,usdCentsPerCoin, hold hours) in its metadata, so changing rates later never rewrites historical financials. - Pure-bonus gifts create no settlement row (zero diamonds moved).
How it works: after the money moves
The database transaction is the source of truth; the animation is just a notification on top. If the socket message is lost, the money is still correct.
The REST response returns the gift event; the live gateway broadcasts it to the room for the animation and leaderboard updates. Socket delivery is best-effort -the ledger is already correct.
How it works: the gift catalog
Gifts themselves are data -rows the client fetches and renders in the tray.
Gift rows: name, coin cost, diamond value, animation asset URL, active
flag. Catalog served by GET /gifts; inactive gifts are rejected at spend
time even if a stale client still shows them.
Tray icons: the client reads an optional iconUrl field per gift from
GET /gifts and renders it in the gift tray. When iconUrl is absent (or
fails to load) the client falls back to a built-in emoji mapped by gift id
(lib/modules/live_room/domain/models/live_gift.dart), with 🎁 as the
default for unknown ids. The backend does not send iconUrl yet -adding
it requires no client change.
Related pages
- Economy -the currencies and rates this transaction moves.
- In-app purchases -where the coins being spent come from.
- Withdrawals -how earned diamonds turn into cash.
- Refunds -why host diamonds survive a viewer's refund.