Drop-in JS + CSS for publisher redemption pages. Wallet-signed redemption flow with safe-by-default key handling — keys never enter the page DOM.
data-key; that pattern leaked keys to extensions, session-replay tools, and DOM snapshots. New required attributes are data-token-id and data-redeem-endpoint. See the migration section below.
<link rel="stylesheet" href="https://flipkey.gg/sdk/redeem/flipkey-redeem.css">
<div class="fk-redeem"
data-platform="steam"
data-token-id="42"
data-redeem-endpoint="/api/redeem"
data-publisher="Acme Games"></div>
<!-- Nothing else to load. The widget lazy-imports the FlipKey wallet
connector itself, so buyers can sign in with a passkey (no wallet
extension, no account setup). Add data-chain-id="8453" for a
Base-mainnet drop; the default is Base Sepolia testnet (84532). -->
<script src="https://flipkey.gg/sdk/redeem/flipkey-redeem.js"></script>
<script>FlipKeyRedeem.mountAll();</script>
The widget renders a Sign in & Redeem button. On click it connects the user's wallet, asks for a signature on a domain-bound message that includes the token ID and a fresh nonce, then POSTs { tokenId, wallet, signature, nonce, issuedAt } to data-redeem-endpoint.
Base Account is the default (passkey/email/social, the wallet FlipKey buyers have). If the buyer has a browser-extension wallet, a small "Use a browser wallet (Coinbase, MetaMask, …)" link appears beneath the button as a fallback.
Set data-platform to one of: steam, xbox, playstation, nintendo, epic. data-publisher is optional and is rendered in the widget heading.
When data-platform is steam or epic, the widget adds an identity-confirmation step before the wallet flow. On mount it calls GET {base}/api/{platform}/me (same origin as your redeem endpoint). If the buyer isn't signed in, the widget renders a "Sign in with Steam/Epic" screen pointing at {base}/api/{platform}/auth; after the OAuth/OpenID round-trip the buyer returns to the page authenticated. The confirmed account id then rides along in the redeem POST as steamId / epicAccountId.
xbox, playstation, and nintendo have no public OAuth, so they skip the gate entirely — the widget goes straight to wallet connect.
Endpoints your backend must expose for a gated platform (relative to the redeem endpoint's origin):
| Endpoint | Method | Returns / does |
|---|---|---|
/api/{platform}/me | GET | Profile (steamId/accountId, display name, avatar) if signed in; 401 or empty otherwise. Reads a same-origin session cookie — the widget sends credentials: 'include'. |
/api/{platform}/auth | GET | Starts the OAuth/OpenID redirect and returns the buyer to the redeem page authenticated. |
/api/{platform}/logout | POST | Clears the identity session (powers the "use a different account" link). |
Why gate at all? For Steam, keys redeem to whichever account is logged into the browser — confirming first prevents activating on the wrong account. For both platforms, a verified platform identity is your input for refund-eligibility and bad-actor screening.
Live widget rendered with v0.2.0 against a stub demo backend that returns a synthetic DEMO-… key. Click the button to walk through the flow — wallet connect, signature, response handoff. (Requires a browser wallet.)
Your data-redeem-endpoint receives:
{
"tokenId": "42",
"wallet": "0xabc...",
"signature": "0x...",
"nonce": "deadbeef...",
"issuedAt": 1730000000000,
"contract": "0x...", // present only for multi-contract publishers
"steamId": "7656119..." // OR "epicAccountId" — present only on gated platforms
}
Your handler must:
signature was produced by wallet over the SDK's message format, using a smart-wallet-aware verifier — viem's action-level verifyMessage (handles EOA, ERC-1271, and ERC-6492). An ecrecover-only check (ethers.verifyMessage) rejects Base Account, the SDK's default smart-contract wallet. See examples/redeem-handler.js.nonce is fresh and within ~5 minutes of issuedAt — replay protection.steamId/epicAccountId is present and record it — it is the second factor and your input for refund-eligibility and bad-actor screening.wallet owns tokenId in your FlipKey-deployed contract.UPDATE keys SET status='redeemed' WHERE status='unredeemed' LIMIT 1).webhook_secret.Cache-Control: no-store on the response and return one of the three result shapes:data-publisher as part of the message, so your handler must rebuild the message with the exact same publisher string or signature verification will fail.
{ "result": "redirect", "url": "https://store.steampowered.com/account/registerkey?key=..." }
{ "result": "entitlement_granted" }
{ "result": "show_key", "key": "XXXXX-XXXXX-XXXXX", "redeemUrl": "https://...", "expiresInSec": 90 }
Use redirect for platforms that accept pre-fill (Steam, PSN). Use entitlement_granted if you used a direct platform API to grant the game without exposing a key (Steamworks GrantPackage, etc.) — preferred when available. Use show_key only for platforms that don't support pre-fill (Xbox, Nintendo, Epic).
Since v0.5.0 the widget does not auto-navigate on a redirect result — it shows the buyer a button to open the pre-filled URL. This is deliberate: auto-redirecting mid-flow could trigger a platform re-login that swallowed the key before the buyer was ready.
Before FlipKey lets you list a game, every redemption URL on the listing must pass a live link test. FlipKey calls your URL with ?fk_test=1&nonce=<random> and expects:
{ "nonce": "<echoed>", "sig": "<hex hmac_sha256(your_webhook_secret, nonce)>" }
This proves the URL exists, runs your handler, and that you control the FlipKey-issued webhook_secret. A drop-in implementation lives in examples/link-test-handler.js.
| Platform | Delivery | Code format | Notes |
|---|---|---|---|
steam | Redirect (pre-fill) | 5x5 with hyphens | Cleanest UX; ?key= attaches the code. Direct entitlement also possible via Steamworks GrantPackage |
xbox | Show-key (paste) | 25-char with hyphens | Microsoft's redeem.microsoft.com doesn't accept pre-fill |
playstation | Redirect (pre-fill) | 12-digit (often 4-4-4) | Sony's ?voucherCode= opens the redemption modal pre-filled |
nintendo | Show-key (paste) | 16-digit | Account must have accessed Switch eShop at least once |
epic | Show-key (paste) | varies | Epic's pre-fill OAuth is a separate gated integration |
The SDK enforces these properties; your backend handler is responsible for the rest.
data-* attribute, value attribute, aria-label, or any other HTML attribute. Browser extensions, third-party scripts (analytics, ads, support widgets, session-replay), and corporate proxies that snapshot DOM state cannot scrape what isn't there.localStorage, sessionStorage, or cookies..textContent, and is auto-redacted after 5 minutes (extended from 90s so buyers have time for Steam Guard / a mid-flow re-login), on tab-hidden, or on navigate-away.Cache-Control: no-store on every response that may contain key material — both your /api/redeem JSON response and the redemption page HTML. CDN caches, corporate proxies, and browser caches will otherwise persist key material.{ result: "entitlement_granted" } and your buyer never sees a code at all.UPDATE keys SET status='redeemed' WHERE status='unredeemed' LIMIT 1 in a transaction. Two concurrent redemption requests must never get the same key.nonces table). A signature is reusable forever otherwise.data-redeem-endpoint.key field from request/response logging in your APM, and from any error reporters (Sentry, Bugsnag, etc.). These services often capture full responses by default.v0.1.x took the key as data-key on the widget element and rendered it directly. That pattern is unsafe and has been removed in v0.2.0. If you are on v0.1.x, you must:
data-key and add data-token-id + data-redeem-endpoint.data-redeem-endpoint URL that follows the contract above.Cache-Control: no-store on both the redemption page response and the redeem JSON endpoint.The reference handlers in examples/ are the fastest path; for most publishers it's a one-day port.
FlipKeyRedeem.mount(document.querySelector('#my-redeem-widget'));
FlipKeyRedeem.mountAll('.fk-redeem'); // selector optional, defaults to '.fk-redeem'
FlipKeyRedeem.PLATFORMS; // platform metadata
FlipKeyRedeem.version; // current SDK version
Per-platform reference pages using the SDK against a stub demo backend (returns synthetic DEMO-… keys, useful for verifying the flow):
Current: 0.6.0. The SDK is pre-1.0 — APIs may change. Pin the version in your script tag once 1.0 ships.
0.6.0 — Wallet layer migrated to Base Account (lazy-imported; passkey/email/social, nothing for the publisher to bundle). Injected EIP-1193 wallet on Base is the fallback. New data-chain-id (84532 Base Sepolia default, 8453 Base mainnet). Replaces the prior Coinbase Smart Wallet / Base-chain default.0.5.0 — Per-platform identity gate for Steam & Epic (OAuth/OpenID sign-in before key release; the platform account id is sent in the redeem POST). Widget no longer auto-redirects — even redirect-platform results render a button the buyer clicks (avoids losing the key to a mid-flow re-login). Key visibility window extended to 5 min. Redeem POST now carries issuedAt, an optional contract, and steamId/epicAccountId when gated. (0.3–0.4 were internal iterations of the gate refactor.)0.2.1 — Coinbase Smart Wallet preferred when coinbase-sdk.bundle.js is loaded; "Use a different wallet" toggle for injected fallback.0.2.0 — Safe redemption flow: keys never enter DOM; wallet-signed POST to publisher backend; show_key auto-redacts. Removed data-key.0.1.x — Deprecated. Server-rendered data-key pattern; do not use.MIT. Source at /sdk/redeem/.