# GiftingStack: the complete integration guide (for AI assistants and developers) This file is written for an AI assistant that has to add GiftingStack to an app, and for the developer reading along. It is self-contained: follow it top to bottom and the integration is complete. Every step says where it runs (DASHBOARD, YOUR SERVER, YOUR APP) and for which platform (WEB, iOS, ANDROID, REACT NATIVE, FLUTTER). Do the steps in order. Do not skip the rules in section 0: most broken integrations break one of them. Start with section 25 (what to ask the person and in what order), and use section 26 (curl smoke test) and section 27 (symptoms and fixes) as you go. Human-readable docs: https://giftingstack.com/docs/ API reference: https://api.giftingstack.com/v1/docs OpenAPI JSON: https://api.giftingstack.com/v1/openapi.json Support: support@giftingstack.com ---------------------------------------------------------------------------------------------- CONTENTS ---------------------------------------------------------------------------------------------- 0. Rules you must never break 1. What GiftingStack is, in one picture 2. Words used in this guide 3. DASHBOARD: account, project, gifts, test key 4. YOUR SERVER: base setup (key, helper, retries) 5. YOUR SERVER: the client token endpoint 6. SELLING COINS. Decide first: mobile app, web, or both 7. MOBILE, iOS: App Store in-app purchases (StoreKit 2), step by step 8. MOBILE, ANDROID: Google Play Billing, step by step 9. MOBILE: React Native / Expo and Flutter apps (which store data to send) 10. WEB: your own checkout, step by step 11. Free coins (bonus credits) and taking coins back (debits) 12. YOUR APP: show the gift tray and send gifts (web, React Native, Flutter, Android, iOS) 13. YOUR APP without an SDK: the client API directly 14. YOUR SERVER: sending gifts from the server, reversing a gift, reading data 15. Webhooks: react to gifts and payments on your server 16. Store notifications (refunds), optional but recommended on live 17. Gift rooms and battles (live streams) 18. Users and privacy: deleting a user 19. Go live: the checklist 20. How the fee is measured (what to tell the customer) 21. Errors: every type and what to do 22. Limits 23. Endpoint reference 24. Final checklist for the assistant 25. START HERE (for the assistant): what to ask, what only a person can do, the order 26. Smoke test: verify each step with curl 27. Symptoms, causes and fixes 28. Things that do not exist, and mistakes to avoid ============================================================================================== 0. RULES YOU MUST NEVER BREAK ============================================================================================== 1. The secret key (gs_test_... / gs_live_...) lives ONLY on the server. Never in an app, a web page, a mobile binary, a repository or a log. Anyone holding it can give any user coins. 2. The app only ever holds a client token (gs_ct_...), minted by your server for the user who is signed in. A client token can send gifts as that one user and read; it can never credit coins. 3. Coins are credited ONLY by your server, ONLY after the payment is confirmed: after the store receipt was verified (mobile) or after your payment provider confirmed the payment (web). Never credit because the app or the browser says "I paid". 4. The server decides how many coins a product gives. Keep the table on the server (for example coins_500 -> 500 coins). Never take the amount from the client. 5. Every coin-moving request carries an Idempotency-Key header: a NEW random UUID for each new operation, the SAME key when you retry that same request. Never use an order number as the key. 6. Every credit and debit carries a reference that is unique forever for that purchase: the store transaction id or your order id. A second credit with the same reference is refused (409 duplicate_reference), which is what stops double crediting. 7. Every credit says what it is: receipt_id (store sale), price + currency (web sale) or "bonus": true (free coins). Exactly one. A credit with none is 400 sale_kind_required. 8. Inside an iOS or Android app, coins are sold with the store's in-app purchase (App Store, Google Play) and credited with the verified receipt. A web checkout is for web sales. Apple's and Google's rules say the same; GiftingStack's Terms (section 8.2) require it. 9. Mobile store purchases: credit FIRST, then finish / consume the purchase in the app. If the credit fails, do not finish it; the store gives it back at the next app start and you retry. 10. Test and live never mix. A gs_test_ key sees only test data and accepts only sandbox purchases; a gs_live_ key only live data and real purchases. 11. Use opaque user ids (your own database id). Never an email address, phone number or name: ids are shown in leaderboards, rooms and webhooks. 12. Receivers never get coins. Sending a gift moves coins from the sender to nobody: the receiver gets a received-gifts counter. There is no payout, no cash-out and no transfer of coins between users. Do not build features that need one. 13. Never log gift message text or metadata (user content). ============================================================================================== 1. WHAT GIFTINGSTACK IS, IN ONE PICTURE ============================================================================================== [user's phone or browser] [YOUR SERVER] [GiftingStack] app + GiftingStack SDK --(sign in)--> your auth <--gs_ct_ token-- POST /v1/client-tokens ------> (secret key) buys coins (store/web) --(receipt)--> POST /v1/receipts/verify ------> valid? once? POST /v1/users/{external_id}/credit ----> balance +500 gift tray (SDK) -------(client token)-------------------------------> POST /v1/client/gift-transactions effect plays <-- webhook gift.sent --------- outbox (reward the receiver your way) - GiftingStack keeps: balances, the gift catalog (pictures, prices), every gift sent, an append-only ledger, leaderboards, gift walls, "while you were away", gift rooms, webhooks. - You keep: your users, sign-in, selling the coins and taking the money, deciding what a received gift is worth to the receiver in your app (if anything). WHERE IT FITS (15 kinds of apps; stories and a feature table: https://giftingstack.com/use-cases/) - Live-streaming apps: viewers gift the host during a stream; gift battles between two or more hosts. - Audio rooms and live voice apps: listeners gift the host and the whole room sees the effect. - Social feeds and short-video apps: gifts on a post, shown under it and on the creator's profile. - Multiplayer games and esports: spectators gift players; big gifts light up the match screen. - Creator and fan communities: supporters send gifts; the creator's profile shows a gift wall. - Music and podcast apps: listeners gift a track or episode; the artist sees a summary later. - Q&A and marketplace apps: thank a helpful answer or a seller with a gift. - Virtual events and webinars: attendees gift speakers during a session, in the session's room. - Team and company recognition apps: each employee gets a monthly coin budget to thank colleagues. - Messaging and community apps: a gift in a conversation, shown as a special message. - Social discovery apps: a small gift before the first message; seen when the person comes back. - Education and language-learning apps: thank a tutor; a class page shows the most thanked. - Fitness and wellness communities: cheer an instructor in a live class; team challenges. - Reading and serial-fiction apps: gift an author at the end of a chapter; gifts counted per chapter. - Photo, art and design sharing apps: gift an artwork; it keeps a small wall of gifts received. REWARDING THE RECEIVER WITH YOUR OWN PRODUCT (https://giftingstack.com/use-cases/#your-own-reward) - Example: a ticketing app adds a "Concert ticket" gift. A user sends it to a friend; the friend's wall shows the ticket; the app's server gets gift.sent and issues a real ticket to the friend from its own system. On gift.reversed it can cancel what it gave. - GiftingStack only records the gift. It does not deliver, guarantee or pay for the reward, and the receiver's gift count stays a counter. There is no API for this; it is your own backend. - The reward is your own offer and responsibility. It must not be money, a cash-redeemable voucher or gift card, or crypto assets (Terms, section 3). - On iOS and Android, coins for gifts that unlock a real ticket or product may need to be sold through your own web checkout instead of in-app purchase (App Store and Google Play rules). SCALE (load-tested on production, November 2026; details https://giftingstack.com/docs/benchmarks/) - One gift room with 250,000 viewers: every gift reached every connected viewer, none twice; typical delivery 0.74 s from the stored gift to the viewer's machine. - 200 gifts a second for 60 s to 40,000 viewers: all 12,000 sends accepted (p95 280 ms), 462 million gift events delivered, none twice; delivery median 0.76 s, p95 0.93 s. Above 10 gifts a second the room groups gifts into one update per frame. - 40,000 viewers held for 48 minutes: all connected, 99.8% still connected at the end. - Method: real WebSocket viewers on 40 separate machines, each with its own client token from the public API; gifts sent through the public API. - What runs it: Cloudflare Workers (API and room connections, at the data center closest to the caller) and Durable Objects (each project's ledger; a large room is split into up to 120 shards that fan gifts out; idle connections hibernate). - Room size per plan: Starter 1,000 viewers, Growth 5,000, Scale 20,000; larger rooms (such as the 40,000 and 250,000 viewer tests) are part of the Enterprise plan. The numbers are results of these runs, not a guaranteed capacity. ============================================================================================== 2. WORDS USED IN THIS GUIDE ============================================================================================== - project: one app. One project = one app (on all its platforms). Two apps = two projects. - environment: test or live. Each has its own users, balances, catalog, ledger, webhooks. - user / external_id: YOUR id for a person, 1 to 128 characters of letters, digits and _ . : - A user exists the first time it is credited or receives a gift. There is no "create user". - coins / units: your in-app currency. You choose its name (coin, gem, star) in the project. - gift: an item in the catalog, with an id (for example pink-tulip), a name, a price in coins, a picture (icon_url) and an effect picture (animation_url). - credit: adds coins to a user. debit: removes coins. - gift transaction: one send of a gift (id gtx_...). It can be reversed once (id grv_...). - client token: gs_ct_..., short-lived, for one user, made by your server. - receipt_id: rcp_..., what POST /v1/receipts/verify returns for a verified store purchase. ============================================================================================== 3. DASHBOARD: ACCOUNT, PROJECT, GIFTS, TEST KEY (done once, by a person, in the browser) ============================================================================================== 1. Open https://app.giftingstack.com and sign in with an email address (a sign-in link is emailed; there is no password). 2. Create a project. Pick the region closest to your server (it cannot be changed later) and name your currency (singular and plural, for example coin / coins). 3. Gifts: a new project starts with an empty catalog in both environments. Open Gifts, click "Choose your gifts", then "Add this set" on a ready-made set (the Starter set is 15 gifts from small to grand), or pick single gifts from the library, or add your own picture. Set prices in coins. Every gift has an id (for example pink-tulip); the app and the server send gifts by that id. Do the same later in the Live environment (Data: Live, at the top): each environment has its own catalog. Use the same gift ids in both. 4. API keys & SDK: create a key with the Test switch on. Copy it now; it is shown once. This is your secret key for development. Store it as an environment variable on your server, for example GS_KEY. 5. Purchases (only for mobile apps): set up the stores. See sections 7 and 8. 6. Webhooks (optional, recommended): add your server's https URL. See section 15. Who can do what: the project owner and invited members both work with data, gifts, test keys and test webhooks. Only the owner manages the team, the plan, live API keys, live webhook endpoints and store credentials, and can delete the project. The owner can hand the project to another member (Team page, "Transfer ownership"): the member becomes the owner and the previous owner a member. A project with a paid plan must have the plan cancelled first. THE GIFT LIBRARY (ready-made gifts and their categories) GiftingStack ships a library of ready-made gifts with pictures, so a project does not need its own artwork. It is public, read-only data (open CORS, cached for a year): https://assets.giftingstack.com/library.json the list (version 1): packs, gifts, sets https://assets.giftingstack.com/gifts//256.webp small picture, for lists (the gift's icon_url) https://assets.giftingstack.com/gifts//512.webp large picture, shown with the effect (animation_url) Human page with pictures: https://giftingstack.com/gifts/ library.json is { "version", "packs", "gifts", "sets" }: gift {"id":"pink-tulip","name":"Pink tulip","pack":"lucky-charms","suggested_price":10,"tags":["flower","rose","bloom","love"],"sort":1} pack {"id","name","description"}: a pack is the library's CATEGORY (every gift is in exactly one) set {"id","name","description","gifts":[gift ids]}: a ready-made starting catalog Prices are suggestions (10 to 50,000 coins); the project sets its own. Gift ids are the ids you send with (pink-tulip); the pictures never change, new gifts get new ids. Sets (3): starter 15 gifts Starter set: A balanced catalog from tiny gifts to grand ones. A good way to begin. everyday 32 gifts Everyday gifts: Small gifts people send all the time. Low prices, lots of variety. big-moments 28 gifts Big moments: Grand gifts for the moments that deserve a show. Add them next to everyday gifts. Packs = categories (25 packs, 386 gifts; gift count, then name and description; read library.json for the current list): fantasy-animals 11 Fantasy animals: Crystal, gold and storm creatures. sky-legends 10 Sky legends: Winged and storm-born legends. champions 16 Champions: Armored heroes and mages. celebration 14 Celebration: Gifts for big moments, from a heart box to a crystal castle. regal-beasts 12 Regal beasts: Crowned and crystal-clad animals. cosmic-treasures 12 Cosmic treasures: Star ships, gems and galaxy crowns. elemental-legends 6 Elemental legends: Fire, ice and water at full power. sweet-treats 11 Sweet treats: Desserts, drinks and snacks. cute-friends 13 Cute friends: Little characters with big smiles. party-time 11 Party time: Cheers, music and celebration. neon-night 11 Neon night: Glowing gear and night-time fun. lucky-charms 17 Lucky charms: Love, luck and little treasures. treasures 14 Treasures: Crowns, gold and trophies. floating-worlds 13 Floating worlds: Tiny islands and cities in the sky. music-stage 7 Music stage: Instruments for the big moments. dream-rides 9 Dream rides: Cars, jets and chariots. legendary-relics 17 Legendary relics: Swords, lamps and magic items. mythic-worlds 12 Mythic worlds: Places that feel like a dream. mythic-beasts 14 Mythic beasts: Great creatures of fire, ice and night. landmarks 9 World landmarks: Famous places as little glowing models. street-food 12 Street food: Snacks and sweets from around the world. festivals 12 Festivals and seasons: Holidays, parties and celebrations. battle-gear 8 Battle gear: Gloves, swords and things that go boom. country-hearts 108 Country hearts: Flags as glossy hearts, for sports nights and national days. clockwork 7 Clockwork workshop: Brass, gears and steam-powered wonders. How a project gets library gifts: - By a person in the dashboard (Gifts, "Choose your gifts"): "Add this set" for a set, or pick single gifts from a pack or search by name or tag. The dashboard fills in id, name, price and both pictures. - By API (a server job, a seed script): read library.json, then POST /v1/gifts for each chosen gift with the library id, your own price and the two picture URLs, for example {"id":"pink-tulip","name":"Pink tulip","price":10,"icon_url":"https://assets.giftingstack.com/gifts/pink-tulip/256.webp", "animation_url":"https://assets.giftingstack.com/gifts/pink-tulip/512.webp"} Do it in the test AND the live environment with the same ids (each has its own catalog). - Licence: library pictures may be shown in an app that runs on GiftingStack; they may not be redistributed as a collection or used outside such an app. Artwork the project uploads stays its own. Categories in YOUR app: a gift in the catalog has no category field (id, name, price, icon_url, animation_url, sort_order, active). Packs only group the library. If your app shows gifts in categories, keep your own map (category -> gift ids, for example built from the pack of each library gift) and filter the list you get from GET /v1/client/gifts. The SDK tray shows the whole active catalog in sort_order. ============================================================================================== 4. YOUR SERVER: BASE SETUP ============================================================================================== Environment variables (never commit them): GS_KEY=gs_test_... # later gs_live_... in production GS_WEBHOOK_SECRET=whsec_... # from the dashboard, section 15 All requests: Authorization: Bearer , JSON bodies (Content-Type: application/json, at most 16 KB). Responses are JSON. Errors have one shape (section 21). A small helper (Node 18+, no dependencies). The same idea works in any language: one function that adds the key, the JSON header and, for writes, an Idempotency-Key that you pass in. ```js // gs.js: the only file that knows the secret key const API = "https://api.giftingstack.com"; export async function gs(method, path, body, idempotencyKey) { const headers = { Authorization: `Bearer ${process.env.GS_KEY}`, "Content-Type": "application/json", }; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; const res = await fetch(API + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), }); const json = res.status === 204 ? null : await res.json(); if (!res.ok) { const err = new Error(json?.error?.message ?? `HTTP ${res.status}`); Object.assign(err, { status: res.status, ...json?.error }); // err.type, err.retryable, err.details throw err; } return json; } // Retry only what the API marks retryable (429 and 5xx), with the SAME idempotency key. export async function gsRetry(method, path, body, idempotencyKey, attempts = 5) { for (let i = 1; ; i++) { try { return await gs(method, path, body, idempotencyKey); } catch (err) { const network = err instanceof TypeError; // fetch failed: unknown outcome, retry is safe if (!(network || err.retryable) || i >= attempts) throw err; await new Promise((r) => setTimeout(r, 2 ** i * 250 + Math.random() * 250)); } } } ``` Requests that need an Idempotency-Key (a missing one is 400 invalid_request): POST /v1/users/{external_id}/credit, POST /v1/users/{external_id}/debit, POST /v1/gift-transactions, POST /v1/gift-transactions/{id}/reverse, DELETE /v1/users/{external_id}, PUT /v1/users/{external_id}/leaderboard-visibility, POST /v1/client/gift-transactions. Idempotency rules: a key is 1 to 255 printable ASCII characters (use a UUID); the same key with the same endpoint and body within 24 hours returns the first answer (header Idempotent-Replayed: true) and changes nothing again; the same key with another body is 422 idempotency_key_reused; only successful answers are stored, so a failed request can be retried with the same key. ============================================================================================== 5. YOUR SERVER: THE CLIENT TOKEN ENDPOINT ============================================================================================== The app needs a client token for the signed-in user. Add ONE endpoint to your backend that your app calls after sign-in. It must check your own session first. ```js // GET /api/gift-token (Express shown; any framework works) import { gs } from "./gs.js"; app.get("/api/gift-token", requireLogin, async (req, res) => { // req.user.id is YOUR id for the person. It becomes the GiftingStack user id. const t = await gs("POST", "/v1/client-tokens", { user: String(req.user.id), // ttl_seconds: 3600, // 60 to 86400, default 3600 // name: req.user.displayName, // only for gift rooms: shown to viewers (1-32 chars) // rooms: ["live_123"], // only for gift rooms: up to 8 ids, a trailing * is a prefix }); res.type("text/plain").send(t.token); // {"token":"gs_ct_...","user":"...","expires_at":"..."} }); ``` - POST /v1/client-tokens needs no Idempotency-Key. - The SDKs call this endpoint again by themselves when the token expires (401 invalid_client_token). - To cut a user off (ban, sign-out everywhere): POST /v1/client-tokens/revoke {"user": ""} or {"token": "gs_ct_..."}. Takes effect within 30 seconds. ============================================================================================== 6. SELLING COINS: DECIDE FIRST ============================================================================================== Which flows you build depends on where your users pay: Your app is... Build ------------------------------------- --------------------------------------------------- an iOS app (any framework) section 7 (App Store, StoreKit 2) an Android app (any framework) section 8 (Google Play Billing) React Native / Expo or Flutter sections 7 and/or 8, plus section 9 a website / web app section 10 (your own checkout, e.g. Stripe) both a mobile app and a website 7/8 inside the app AND 10 on the website In all cases the end of the flow is the same server call: POST /v1/users/{external_id}/credit, from your server, with exactly one kind: Store sale (App Store, Google Play) {"receipt_id": "rcp_..."} no price, no currency Web sale (your own checkout) {"price": "4.99", "currency": "USD"} no receipt_id Free coins (reward, welcome, support) {"bonus": true} no price, no receipt Common fields: amount (1 to 1,000,000,000 coins), reason (lowercase letters, digits, _ ; for example purchase), reference (unique forever; your order or store transaction id), optional metadata (small JSON object, no personal data). GiftingStack does not keep a product list. Your server maps each product to a number of coins. ============================================================================================== 7. MOBILE, iOS: APP STORE IN-APP PURCHASES (STOREKIT 2), STEP BY STEP ============================================================================================== A. App Store Connect (once) 1. In your app, create In-App Purchases of type Consumable, one per coin pack, for example product ids coins_100, coins_500, coins_1200. Give each a price tier. 2. Create Sandbox testers (Users and Access, Sandbox) for testing with the test key. B. GiftingStack dashboard (once, project owner) 3. Purchases, Mobile apps: add the app's bundle id (for example com.example.myapp). Do it in the Test environment and in the Live environment. Nothing else is needed for Apple: Apple transactions are verified offline and carry their own price. C. YOUR SERVER: a "purchase" endpoint the app calls after a purchase 4. The coin table, on the server: ```js const COINS = { coins_100: 100, coins_500: 500, coins_1200: 1200 }; // your products ``` 5. The endpoint verifies, then credits with the receipt_id: ```js import { randomUUID } from "node:crypto"; import { gs, gsRetry } from "./gs.js"; // POST /api/purchases/apple body: { jws: "" } app.post("/api/purchases/apple", requireLogin, async (req, res) => { const userId = String(req.user.id); const v = await gs("POST", "/v1/receipts/verify", { platform: "apple", signed_transaction: req.body.jws, // Transaction JWS, not the old base64 app receipt user: userId, }); // Refunded or cancelled: nothing to grant. Answer 200 so the app finishes it and stops retrying. if (!v.valid) return res.json({ granted: false, status: v.status }); const coins = COINS[v.product_id]; if (!coins) return res.status(400).json({ granted: false, reason: "unknown product" }); // v.already_seen: verified before (maybe a retry after a crash). Credit anyway: if it was // already credited, the credit answers 409 and nothing is added twice. try { await gsRetry( "POST", `/v1/users/${encodeURIComponent(userId)}/credit`, { amount: coins, reason: "purchase", reference: `apple_${v.transaction_id}`, // unique forever receipt_id: v.receipt_id, // store sale: no price, no currency }, randomUUID(), // keep it with your order record if you retry later ); } catch (err) { if (err.type !== "duplicate_reference" && err.type !== "receipt_already_credited") throw err; // already credited: fall through and let the app finish the transaction } res.json({ granted: true }); }); ``` D. YOUR APP (Swift, StoreKit 2) 6. Buy, send the signed transaction to your server, and finish it ONLY after the server answered: ```swift import StoreKit func buy(_ product: Product) async throws { let result = try await product.purchase() switch result { case .success(let verification): try await deliver(verification) case .pending, .userCancelled: break @unknown default: break } } // Sends the JWS to your server; finishes the transaction only when the coins are credited. func deliver(_ verification: VerificationResult) async throws { let jws = verification.jwsRepresentation var request = URLRequest(url: URL(string: "https://your.app/api/purchases/apple")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(["jws": jws]) // + your own session header let (_, response) = try await URLSession.shared.data(for: request) guard (response as? HTTPURLResponse)?.statusCode == 200 else { return } // retried later if case .verified(let transaction) = verification { await transaction.finish() } } // At app start: deliver purchases that were paid but never finished (crash, no network). func listenForTransactions() -> Task { Task.detached { for await update in Transaction.updates { try? await deliver(update) } } } ``` 7. Then refresh the balance in the app (the SDK does it after the next request; or call GET /v1/client/me). E. Test it 8. Use the gs_test_ key on the server and a Sandbox tester on the device. A sandbox purchase verified with a live key, or a real purchase with a test key, is 422 environment_mismatch. Xcode StoreKit configuration-file transactions are not signed by Apple and are refused: test with a sandbox account. What the verify answer looks like: ```json { "valid": true, "status": "purchased", "receipt_id": "rcp_01JA...", "platform": "apple", "environment": "live", "transaction_id": "2000000712345678", "original_transaction_id": "2000000712345678", "product_id": "coins_500", "purchased_at": "2026-10-13T09:00:00.000Z", "price": "4.99", "currency": "USD", "price_source": "store", "price_missing": null, "quantity": 1, "already_seen": false, "first_seen": null, "google": null } ``` ============================================================================================== 8. MOBILE, ANDROID: GOOGLE PLAY BILLING, STEP BY STEP ============================================================================================== A. Play Console (once) 1. Monetize with Play, Products, In-app products: create one product per coin pack, for example coins_100, coins_500, coins_1200. They are consumed after each purchase. 2. Add license testers (Settings, License testing) for testing with the test key. 3. Create a Google Cloud service account (in the Cloud project linked to Play Console), create a JSON key for it, and in Play Console, Users and permissions, invite its email with the permission "View financial data" for this app (and "Manage orders and subscriptions" is not needed). Without View financial data Google does not give us the price, and on live such a purchase cannot be credited (422 price_missing). B. GiftingStack dashboard (once, project owner) 4. Purchases, Mobile apps: enter the package name (for example com.example.myapp) and upload the service account JSON key. Do it in Test and in Live. The key is stored encrypted. C. YOUR SERVER 5. Same coin table as iOS (keyed by product id). 6. Verify, then credit: ```js // POST /api/purchases/google body: { purchaseToken, productId } app.post("/api/purchases/google", requireLogin, async (req, res) => { const userId = String(req.user.id); const v = await gs("POST", "/v1/receipts/verify", { platform: "google", purchase_token: req.body.purchaseToken, product_id: req.body.productId, user: userId, }); if (!v.valid) { // "pending": not paid yet. Do not grant and do not consume; the app tries again when paid. // "cancelled" / "revoked": nothing to grant. return res.status(v.status === "pending" ? 409 : 200).json({ granted: false, status: v.status }); } const coins = COINS[v.product_id] * (v.quantity ?? 1); try { await gsRetry( "POST", `/v1/users/${encodeURIComponent(userId)}/credit`, { amount: coins, reason: "purchase", reference: `google_${v.transaction_id}`, // the Google order id, unique forever receipt_id: v.receipt_id, }, randomUUID(), ); } catch (err) { if (err.type === "price_missing") { // Live: Google did not give the price. Give the service account "View financial data", // then verify the same purchase again and credit. Do not consume it yet. return res.status(503).json({ granted: false }); } if (err.type !== "duplicate_reference" && err.type !== "receipt_already_credited") throw err; } res.json({ granted: true }); }); ``` D. YOUR APP (Kotlin, Play Billing Library) 7. Buy, send the token to your server, consume ONLY after the server answered: ```kotlin // After launchBillingFlow(...), in your PurchasesUpdatedListener: override fun onPurchasesUpdated(result: BillingResult, purchases: MutableList?) { if (result.responseCode != BillingClient.BillingResponseCode.OK || purchases == null) return purchases.forEach { handle(it) } } fun handle(purchase: Purchase) { if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) return // pending: wait scope.launch { val granted = myBackend.postGooglePurchase( // POST /api/purchases/google purchaseToken = purchase.purchaseToken, productId = purchase.products.first(), ) if (granted) { // Consuming also acknowledges. Unconsumed, unacknowledged purchases are refunded by // Google after 3 days, so do not leave them. billingClient.consumeAsync( ConsumeParams.newBuilder().setPurchaseToken(purchase.purchaseToken).build(), ) { _, _ -> } } } } // At app start: handle purchases that were paid but never consumed. billingClient.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build(), ) { _, list -> list.forEach { handle(it) } } ``` E. Test it 8. gs_test_ key plus a license tester account on the device. Test purchases verified with a live key (or real ones with a test key) are 422 environment_mismatch. Google-specific answers from /v1/receipts/verify: valid is true only for a paid purchase (status purchased); pending purchases are not recorded, so verify again once paid; 422 receipt_not_found means Google does not know this token for your package; 422 store_credentials_rejected means Google refused the service account (check the key and its permissions); 409 receipts_not_configured means step 4 is missing; 503 store_unavailable means Google did not answer (retry later). ============================================================================================== 9. MOBILE: REACT NATIVE / EXPO AND FLUTTER APPS ============================================================================================== Use any in-app purchase library for your framework (for example react-native-iap or expo-iap on React Native, in_app_purchase on Flutter). The server side is exactly sections 7 and 8. What the app sends to your server: - iOS: the StoreKit 2 signed transaction (a JWS string). Libraries built on StoreKit 2 expose it on the purchase object (the transaction's jwsRepresentation). Do not send the legacy base64 app receipt: it is not accepted. - Android: purchaseToken and the product id from the purchase. Then, only after your server answered { granted: true }: finish the transaction (iOS) or consume the purchase (Android) with the same library (finishTransaction / completePurchase / consume). At app start, process unfinished purchases the library reports. ============================================================================================== 10. WEB: YOUR OWN CHECKOUT, STEP BY STEP ============================================================================================== On a website, you sell coins with your own payment provider (Stripe, Paddle, Adyen, PayPal...). GiftingStack never sees the card or the money; it records what was paid, for the fee. 1. Products: a coin pack table on your server, with your price per pack: ```js const PACKS = { coins_500: { coins: 500, price: "4.99", currency: "USD" } }; ``` 2. Checkout: your server creates the checkout session with your provider and stores an order (order id, user id, pack). The browser never decides the coins. 3. Credit when YOUR PROVIDER confirms the payment, from your server, in the provider's webhook handler (for Stripe: checkout.session.completed / payment_intent.succeeded): ```js // inside your payment provider's webhook handler, after you verified ITS signature const order = await db.orders.findByProviderId(event.data.object.id); if (order.creditedAt) return; // your own guard; the reference below is the hard guard await gsRetry( "POST", `/v1/users/${encodeURIComponent(order.userId)}/credit`, { amount: PACKS[order.pack].coins, reason: "purchase", reference: `web_${order.id}`, // unique forever price: PACKS[order.pack].price, // what the user paid, a decimal string, major units currency: PACKS[order.pack].currency, // ISO 4217 }, order.idempotencyKey, // a UUID stored with the order when it was created ); await db.orders.markCredited(order.id); ``` 4. Rules for price and currency: - price is a decimal string in major units with at most the currency's decimals ("4.99", "500" for JPY, "1.250" for KWD), greater than 0. - currency is an ISO 4217 code. On live it must be USD, EUR or a currency of the European Central Bank's reference rates (GBP, JPY, CHF, TRY, PLN and so on); another code is 400 currency_not_supported. Convert to one of those in your shop if needed. - Declare what the user really paid for these coins. The price never changes a balance; it is only how the fee is measured. 5. Refunds on the web: when your provider refunds, take the coins back with a debit (section 11) if your policy says so. A web app then shows the tray with the web SDK or calls the client API directly (sections 12 and 13). ============================================================================================== 11. FREE COINS (BONUS) AND TAKING COINS BACK (DEBIT) ============================================================================================== Free coins (welcome gift, daily reward, compensation): ```bash curl -X POST https://api.giftingstack.com/v1/users/ava/credit \ -H "Authorization: Bearer $GS_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"amount": 100, "reason": "welcome_bonus", "reference": "welcome_ava", "bonus": true}' ``` Up to 20% of the coins you sell each month can be given away free without counting in the fee. Debit (spend coins outside gifts, or take back a refunded purchase): ```bash curl -X POST https://api.giftingstack.com/v1/users/ava/debit \ -H "Authorization: Bearer $GS_KEY" -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"amount": 500, "reason": "refund", "reference": "refund_order_1001", "allow_negative": true}' ``` - Without allow_negative a debit larger than the balance is 422 insufficient_balance. - allow_negative: true is for clawbacks (refunds, chargebacks): the balance may go below zero and the user cannot send gifts until it is positive again. - A credit and a debit may share a reference; two credits (or two debits) may not. ============================================================================================== 12. YOUR APP: SHOW THE GIFT TRAY AND SEND GIFTS ============================================================================================== The SDKs give you: a client that fetches and refreshes the token, the gift tray (a bottom sheet on phones; a popover or inline panel on the web), the full-screen gift effects with combos, gift walls, the leaderboard, the "while you were away" summary, gift rooms and the battle bar. The SDK code is MIT licensed. Every snippet below assumes the token endpoint from section 5. WEB (any framework or none). npm i @giftingstack/sdk ```js import { createGiftingClient } from "@giftingstack/sdk"; import { GiftEffectLayer, createGiftTray } from "@giftingstack/sdk/web"; const client = createGiftingClient({ getToken: async () => (await fetch("/api/gift-token")).text(), }); const screen = document.querySelector("#screen"); // your container element const effects = new GiftEffectLayer(screen); // plays the effects over it const tray = createGiftTray({ container: screen, client, receiver: post.authorId, // who gets the gift (your user id) reference: post.id, // optional: your context (post, stream, match) effects, onTopUp: openCoinStore, // your coin store (section 10) // anchor: giftButton, // on wide screens the tray is a popover next to this button // collapseOnSelect: true, // a pick makes the tray small (picked gift + Send) // scrim: true, scrimTap: "dismiss", // a dim behind the sheet; a tap on it closes the tray }); giftButton.onclick = () => tray.toggle(); // open if closed, close if open // When the screen goes away: tray.destroy(); ``` Custom elements also exist: , , , , , , . REACT NATIVE / EXPO (react 19.1+, react-native 0.80+). npm i @giftingstack/sdk ```tsx import { useRef } from "react"; import { View } from "react-native"; import { createGiftingClient } from "@giftingstack/sdk"; import { GiftEffectLayer, GiftTray, useGiftTray } from "@giftingstack/sdk/react-native"; const client = createGiftingClient({ getToken: async () => (await fetch("https://your.app/api/gift-token")).text(), }); function PostScreen({ post }) { const effects = useRef(null); const tray = useGiftTray({ client, receiver: post.authorId, effects, reference: post.id }); return ( {/* your screen */} ); } ``` All five SDKs report the platform (X-GiftingStack-Platform) and, on phones, the phone's OS (X-GiftingStack-OS) by themselves. FLUTTER (package giftingstack; Dart 3.6+) ```dart final client = GiftingClient( getToken: ({required bool forceRefresh}) => myBackend.giftingToken(forceRefresh: forceRefresh), ); final effects = GiftEffectController(); final tray = GiftTrayNotifier(client: client, receiver: 'mateo', effects: effects); Stack(children: [ YourScreen(), Positioned.fill( child: GiftTray(tray: tray), ), Positioned.fill(child: GiftEffectLayer(controller: effects)), ]); ``` ANDROID (Kotlin + Jetpack Compose, minSdk 24) ```kotlin val client = GiftingClient(getToken = { forceRefresh -> myBackend.giftingToken(forceRefresh) }) @Composable fun PostScreen(client: GiftingApi) { val effects = rememberGiftEffectLayerState() val tray = rememberGiftTrayHolder(client, receiver = "mateo", effects = effects) Box(Modifier.fillMaxSize()) { /* your screen */ GiftTray(tray = tray) GiftEffectLayer(effects) } } ``` iOS (Swift + SwiftUI, iOS 15+; Swift package, product GiftingStack) ```swift import GiftingStack import SwiftUI let client = GiftingClient(getToken: { forceRefresh in try await MyBackend.shared.giftingToken(forceRefresh: forceRefresh) }) struct PostScreen: View { @StateObject private var effects = GiftEffectController() @StateObject private var tray = GiftTrayModel(client: client, receiver: "mateo") var body: some View { ZStack { PostView() GiftTray(tray: tray) GiftEffectLayer(controller: effects).ignoresSafeArea() } .onAppear { tray.effects = effects; tray.start() } .onDisappear { tray.stop() } } } ``` SDK behaviour you can rely on (all five platforms): - Test mode badge: the tray shows a "Test mode" badge by itself while GET /v1/client/me answers livemode:false (the token was made with a test key). It comes from the tray state, so pass the state holder: React Native GiftTray tray={useGiftTray(...)}, Flutter GiftTray(tray: notifier), Android GiftTray(tray = holder), iOS GiftTray(tray: model); the web createGiftTray and do it themselves. testMode is optional: unset follows the state, false forces the badge off, true forces it on. testModeLabel changes the text. Gifts, balance and onSend you pass yourself win over tray. - Dim behind the sheet, outside tap and your Gift button (bottom sheet; everything is off or unchanged by default): scrim: true draws a dim behind the sheet over the whole screen. It fades in over 200 ms (at once when animations are off), is hidden while the sheet is at peek (the page or stream stays in sight) and takes the taps above the sheet, at peek too: a tap on the empty space beside a small tray closes it and the next tap reaches your page (leave scrim off if the page must stay touchable beside a small tray). scrimTap says what a tap on it does: "lower" (default; Dart ScrimTap.lower, Kotlin ScrimTap.Lower, Swift .lower) lowers a tall sheet to peek and closes a peeking one, "dismiss" (ScrimTap.dismiss, ScrimTap.Dismiss, .dismiss) closes the tray from every state. onDismiss is called after the tray closed. The state holder owns the open state, so your Gift button is one line: tray.toggle() (open if closed, close if open), also tray.open(), tray.close() and tray.isOpen. Start closed with open: false (web createGiftTray option, React Native useGiftTray({ open: false }), Flutter GiftTrayNotifier(open: false), the Android and iOS holder's open parameter). A closed tray is invisible, takes no touches and reports an inset of 0; opening shows it again at initialSnap. React Native and Flutter: pass open to GiftTray yourself to own the state. The web popover keeps its own Escape and click-outside behaviour. - Gift button in YOUR message bar (chat, comment bar), like an emoji button: GiftingStack has no chat; it fits into yours. Put the SDK's GiftButton inside or next to your own text field and give the tray that field. Then the SDK does the swap: the button closes the keyboard and opens the tray in the keyboard's place (on a phone about the keyboard's height, docked at the bottom, no dim, so the chat stays visible); a tap on the field closes the tray and the keyboard comes back; the button again does the same. With a field the tray starts closed. Everything else of the tray stays (collapseOnSelect, onSent, onInsetChange to lift your bar above it, the web popover on wide screens). Draw the gift line in your chat yourself from onSent. Web: const btn = new GiftButton(bar, { theme }); createGiftTray({ ..., anchor: btn.element, input: textField }) (elements: and ). React Native: const field = useRef(null); const tray = useGiftTray({ ..., input: field }); . Flutter: GiftTrayNotifier(input: focusNode) + GiftButton(tray: notifier). Android: Modifier.giftTrayInput(holder) on your TextField + GiftButton(tray = holder). iOS: .giftTrayInput(model, focused: $isFocused) on your TextField + GiftButton(tray: model). GiftButton options: label ("Send a gift"), filled (false: the glyph in the accent colour; true: an accent circle), size (small 32, medium 36 default, large 44: three fixed sizes, never a free number), icon (your own glyph instead of the gift: fitted into a fixed box, scaled down only, clipped, so it cannot change the button size; it gets the glyph colour; web: SVG markup string or Node, React Native: node or (color) => node, Flutter: (context, color) => widget, Android: { color -> ... }, iOS: { color in AnyView(...) }), disabled, theme. Docs: https://giftingstack.com/docs/customize/#gift-button - Plain-text URLs: a baseUrl starting with http:// (and a room url starting with ws://) is refused with the platform's argument error (iOS: a precondition that stops the app; never a GiftingError) unless the host is localhost, 127.0.0.1, ::1 or 10.0.2.2, or you pass allowInsecure (web element: ). The token travels in every request. Use https:// and wss://. - Timeout: every request attempt times out after 20 s by default: web and React Native timeoutMs (ms, 0 = none), Flutter timeout (Duration, Duration.zero = none), iOS timeout (seconds, 0 = the system default); Android uses 15 s connect and 30 s read. A timeout and any other failure to reach the server is a GiftingError with status 0 and type network_error. - invalid_response: a 2xx answer whose body is not valid JSON of the expected shape is a GiftingError with the HTTP status and type invalid_response, carrying no part of the body. The SDKs raise it; the API never sends it. It is not retried. - Retries: a 401 gets one new token and one repeat; a 5xx gets one quiet repeat; a send that got no answer at all (connection lost, not a timeout) is repeated once with the SAME idempotency key, so a gift is never charged twice. 429 and other 4xx are never retried. - Pictures: only https://, data:image/ and http:// to localhost, 127.0.0.1, ::1 or 10.0.2.2 are loaded (web and React Native also paths on your own origin); any other URL is not requested and the placeholder is drawn. Where the platform lets the SDK stop a download (Flutter, Android, iOS) a picture is at most 5 MiB. A picture that failed is not requested again for 60 s. The tray loads every gift's small icon when the catalog arrives and the big picture (animation_url) only for the selected gift; other big pictures load when first played. - Web Content-Security-Policy: no inline script, no eval, no style attributes. connect-src needs https://api.giftingstack.com (your own host if you proxy), wss://rt.giftingstack.com (rooms) and https://assets.giftingstack.com (remote recipes; not with remoteRecipes:false); img-src needs https://assets.giftingstack.com, your own picture hosts and data: for data URLs. style-src needs nothing where the browser has adoptedStyleSheets (Chrome 73, Firefox 101, Safari 16.4); older browsers get a