Sell your game’s own currency through indie.fun’s checkout. Buyers pay straight to your wallet, and your server credits the coins.
Your game has its own currency, gems, gold, credits, whatever you call it. indie.fun is the checkout that sells it: your server opens a purchase, the player pays in whichever currency you said you would take, and the money arrives in your wallet. indie.fun never holds it.
indie.fun is the payment processor and nothing more. It does not know what a coin is, what it buys, or how many a player holds. It knows the price of the one purchase it is settling, because it has to build a transfer for that amount and write down what was paid. Everything else is yours: the currency, the catalogue, the balance and the crediting.
indie.fun stores no product list
What you sell, what you call it, what it costs and what you take for it are decided per purchase, in your code. A weekend sale or a new bundle is a change in your game, not a product list here that would only drift out of step with it.Coins are per-game. A coin bought in your game spends only in your game. If you want a currency players already hold when they arrive, that is Ruby, which is a different thing and settled entirely by indie.fun.
Open your game’s Payments tab and paste the Solana address that should receive payments. Until it is set, opening a checkout fails, because there would be nowhere for the money to go.
The wallet lives on your game rather than on a purchase, deliberately. A destination your server could name per request is a destination a compromised server can redirect, and you would never see it happen.
Rehearse on devnet first
The same tab has a network switch. On devnet nothing moves real money, payouts stay off the public payouts feed, and you can run the whole flow end to end with free tokens. Every currency below works there: devnet USDC, SOL priced at the real market rate, and your own devnet mint at a flat dollar each, since no market quotes one. So the accept list you rehearse with is the one you ship. Switch to mainnet when the wiring is right. Purchases already made keep the network they were made on.From your server, name the player, what they are buying, the price and what you will take for it. You get back a URL. Send the player there.
import { Indie } from 'indie-sdk/server';
const indie = new Indie({ appId: process.env.INDIE_APP_ID, appSecret: process.env.INDIE_APP_SECRET });
const { ok, url, error } = await indie.createCheckout(player.id, {
item: '5,500 Coins', // what the player sees at checkout
priceCents: 499, // $4.99
coins: 5500, // carried on the receipt, for you to read back
reference: `shop-${cartId}`, // your id for this purchase
accept: [
{ currency: 'usdc' }, // converted from the price at a live rate
{ currency: 'sol' },
{ currency: 'token', mint: MY_MINT, amount: '4000', label: '$GEM' },
], // omit accept entirely and it is USDC only
});
if (!ok) return fail(error);
redirect(url);This needs your App Secret, so it only ever runs on your server. A browser that could open a checkout is a browser that could open one at any price it liked.
Hosted on indie.fun? Open it over your game instead
Sending the player to that URL works anywhere. But when your game is hosted here, it runs in a frame with indie.fun around it, and the page can put the checkout on top of your game rather than replacing it. Post the order id to the page that frames you:
if (window.parent !== window) {
const orderId = url.split('/').pop();
window.parent.postMessage({ type: 'indiefun:open-checkout', orderId }, '*');
} else {
location.href = url; // played anywhere else
}The id, never the URL: indie.fun builds the address it frames, so no game can name the page that appears inside indie.fun’s own chrome. When the purchase settles, the overlay closes and your frame receives { type: 'indiefun:coin-order', status: 'paid' } — the cue to ask your server what was paid for, exactly as the returning-player step below does.
Your game cannot draw this itself: the frame is sandboxed onto an opaque origin, so a checkout you embed inside it is cross-site with indie.fun and would carry no session, reporting the player signed out.
reference is what makes a retry safe
Opening a checkout twice under onereference returns the first one instead of charging again, so a request you retried after a timeout and a double-tapped buy button are both one purchase and one URL. The result’s replay tells you which happened. Leave it out and every call opens a new purchase.usdc, sol, or token with a mint. See below.You decide the rate, or we do
Give a currency an amount and it is charged exactly that, with no market consulted at any point. That is how you sell for your own token before anything lists it, and how you fix a price you have already advertised.
Leave amount out and the cents are converted at a live rate, optionally with a discountPercent off the price first, which is the usual way to make paying in your own token the cheaper option.
A rail we cannot price is not offered rather than guessed at, so a token nothing lists yet needs an amount. One unpriceable currency never takes the others down with it, and when you open your own checkout while signed in as the game’s owner, the page tells you which one was left out and why.
After paying, the player returns to your game. Ask us whether the purchase was really paid, rather than taking their word for it, then credit the coins.
const order = await indie.getCoinOrder(orderId);
if (order?.status === 'paid') {
await creditOnce(order.id, order.buyerId, order.coins);
}Credit against order.id, and make it credit once. The same purchase reaches you more than once by design: here, and again in the sweep below. The id is what makes the second time do nothing.
Nothing is credited for you
indie.fun settles the payment and keeps the receipt. Adding coins to a balance is your game’s job, because your game is the only thing that knows what a coin is.A player can pay and then close the tab, so the return trip in step 3 is not guaranteed to happen. Poll for paid purchases and credit anything the return trip missed. Keep the last paidAt you saw and pass it back as since to walk forward.
const orders = await indie.listCoinOrders({ since: lastPaidAt, after: lastId });
for (const order of orders) {
await creditOnce(order.id, order.buyerId, order.coins);
lastPaidAt = order.paidAt; // the cursor for next time
lastId = order.id; // and the tie-breaker, see below
}A request that fails resolves to an empty list rather than throwing. That is the safe direction for a poller: your cursor does not advance, so the next sweep asks for the same window again and no purchase is skipped.
Keep after alongside since. Two purchases can be paid in the same millisecond, and a cursor made only of a timestamp cannot tell them apart: if a page ends between them, the second is never returned again. The order id breaks the tie. Sending only since still works, and still has that gap.
The sweep above is how you learn the truth, and it stays that way. A webhook is how you learn it quickly: set a URL on your game and we POST to it the moment an order is paid, so coins land while the player is still looking at the shop rather than on your next poll.
PUT /api/me/creations/<gameId>/webhook { "url": "https://your.game/indie-hook" }
→ { "url": "…", "secret": "whsec_…" } // shown onceThe webhook says an order moved. It is not proof that it did.
Verify the signature, then look the order up before crediting anybody. The body carries an id and a status and nothing else worth trusting — the point of the design is that your coin balance never depends on our being able to authenticate a request you cannot watch us send.
Deliveries can be lost, delayed, or repeated, and none of that costs you anything: the sweep settles the same order regardless. There is no retry queue for exactly that reason.
import { createHmac, timingSafeEqual } from 'crypto';
app.post('/indie-hook', express.raw({ type: 'application/json' }), async (req, res) => {
const expected = createHmac('sha256', process.env.INDIE_WEBHOOK_SECRET)
.update(req.body) // the exact bytes, not the parsed object
.digest('hex');
const given = req.get('x-indie-signature') || '';
if (expected.length !== given.length ||
!timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
return res.sendStatus(400);
}
const { orderId } = JSON.parse(req.body);
const order = await indie.getCoinOrder(orderId); // the truth, not the body
if (order?.status === 'paid') await creditOnce(order.id, order.buyerId, order.coins);
res.sendStatus(204);
});Sign over the raw bytes, not a re-serialised object: JSON.stringify of a parsed body is not always the same string, and a signature over the wrong bytes fails for reasons that look like a bug in us. The URL must be https, since the signature is what makes a delivery believable and a plaintext one can be lifted and replayed.
createCheckout(userId, { item, priceCents, coins, reference, accept })Open a purchase. Resolves { ok, url, order, replay, error }. Send the player to url.getCoinOrder(orderId)One purchase, whatever state it is in. What to call when a player returns from checkout.listCoinOrders({ since })Every paid purchase after that instant, oldest first. Resolves [] on failure, so a cursor never skips.There is deliberately no way to open a checkout from the browser. All three of these cost the App Secret, which never belongs in a page. The raw HTTP endpoints behind them are on the API Reference.
Check it worked
Put your game on devnet, set a payout wallet, and run createCheckout for a player. You should get a url back.
Open it. The checkout page names your game, the item and the price. Pay it with a devnet wallet, and your game’s Payments tab counts the revenue.
Then call getCoinOrder with that id. A status of paid is the receipt, and the point where your own crediting should run.
If opening a checkout fails, the error says which of the three usual causes it is: no payout wallet set on the Payments tab, a userId that is not a real indie.fun account, or a price outside 25 to 50000 cents.
If a payment went through but nothing was credited, the purchase is not lost: listCoinOrders returns it, which is exactly what the sweep in step 4 is for. The Payments tab lists every paid purchase under the revenue, so that is where you confirm the money arrived and find the order id; crediting the player is still the sweep’s, because that page only reads. Check Authentication is working first: crediting needs a player id, and every purchase is opened against one.