Phygitals IFrame API
The IFrame API embeds the full Phygitals pack-opening experience in your product. Players never leave your app and never create a Phygitals account — they play as a user of yours.
Phygitals never holds player funds. You stay the system of record for balances: every time a player spends or is paid, Phygitals calls your wallet server-to-server and acts on your answer.
How it works
The integration runs in two directions, and each is authenticated differently.
| Direction | Call | Authenticated by |
|---|---|---|
| You → Phygitals | launch — mint an iframe URL for one player |
Your X-API-Key |
| Phygitals → You | Wallet operations | RSA signature over the request body |
Three steps to integrate:
- Call
launchfrom your backend with the player's id. You get back a single-use URL. - Embed it in an
<iframe>. The player opens packs inside it. - Expose your wallet — Phygitals calls it as the player spends, wins, keeps and gets refunded.
The wallet contract
Your wallet exposes five operations — four that change state, and one read.
| Operation | Money | Purpose |
|---|---|---|
debit |
out | Charge the player — a pack, a shipping fee |
credit |
in | Pay the player — a card sold back |
rollback |
reversed | Undo a charge that shouldn't stand |
recordAsset |
none | Book a card the player kept |
balance |
— | What the player has to spend |
The paths and field names below are canonical, not mandatory. If your platform already exposes a
seamless wallet — the iGaming bet / win / rollback surface, or anything like it — Phygitals
writes the adapter that maps these onto your endpoints and you keep your wire. What you cannot
change is what each operation means, and that is what this section specifies.
Common fields
Every call carries these. Anything your wire has no place for is simply not sent.
| Field | Type | Description |
|---|---|---|
user_id |
string | Your player id, verbatim as you sent it to launch. |
token |
string | The player-session token from launch, if you sent one. |
transaction_id |
string | This call's id, minted by Phygitals. Unique. A repeat is a retry. |
amount |
integer | Minor units — 5800 is $58.00. Always positive; the operation names the direction. |
reason |
string | Human-readable context, e.g. pack:starter-pokemon x2. For your statements and support tooling. |
reference_transaction_id |
string | On rollback, the charge being undone. |
game_code |
string | Which registered wallet game this belongs to. |
Every operation returns the player's resulting balance, in the same minor units:
{ "balance": 8805, "currency": "USD" }
balance
Read-only. The embedded UI shows this to the player and refreshes it after each transaction. Return their current spendable balance. Nothing here moves.
debit
Charge the player. Fires when they buy a pack (amount is the pack price × quantity) or pay a
shipping fee to claim cards for physical delivery.
Refuse what you cannot cover. Insufficient funds is a normal answer, not an error — return your business refusal and Phygitals surfaces it to the player. Getting that distinction wrong is the one mistake that causes real accounting damage; see Your response decides what happens next below.
credit
Pay the player. Fires when they sell a card back (amount is the buyback price), or when a card
is auto-sold because they left it undecided past the claim window — currently 1 hour after
the pull.
A credit is only sent after the sale has committed on our side. The player is owed the money whatever happens to the request, which is why a failed credit is retried and a failed debit is not.
rollback
Reverse a charge that should not stand. Fires when the pack draw fails after the player was debited, when the shipment fails to book after the fee was debited, or when a debit's outcome was never learned and the charge may or may not exist.
A rollback carries two ids. transaction_id is the reversal's own id — it is a transaction in
its own right, so a retry reusing it must not reverse twice. reference_transaction_id is the charge
being undone.
A rollback for a charge you never took is expected, not exceptional. When we don't learn a debit's outcome we reverse it defensively. Return your "no such transaction" code and we treat it as nothing to undo.
recordAsset
No money moves. The player claimed a card for physical shipping, so it leaves the system and will
never be sold back to them. This books the asset against the player: what they now hold, and what you
owe Phygitals for it. Fires once per card, after the shipment is booked and the fee is charged.
amount is what the card was worth — its buyback value at the moment it was claimed.
This operation is optional. If you have no asset concept, don't expose it — Phygitals sends nothing at all for a kept card.
Settlement rules
These rules govern every wallet call. Together they are what make the integration safe to retry.
Transaction ids
Every call carries a transaction_id minted by Phygitals and written to our own ledger before the
request leaves. When a call is retried, the id is read back off that row — never regenerated.
That gives you one rule to implement: treat transaction_id as unique per player. A repeat is a
retry, not a new transaction. Return the original result and do not apply it twice.
debit is the deliberate exception. A player retrying a failed purchase is making a new purchase,
so it gets a new id and is a new charge. Phygitals never re-sends a debit.
Your response decides what happens next
This is the rule that matters most. Phygitals sorts every wallet response into three buckets:
| Your response | We read it as | What we do next |
|---|---|---|
| Success, with the resulting balance | applied | Continue. |
| A business refusal you answered — insufficient funds, unknown transaction | declined — definitively nothing moved | Fail the player's action. No reversal. |
Timeout, connection failure, 5xx, unparseable body |
unknown — it may have been applied | Reverse a debit. Replay a credit. |
The exact codes are mapped at onboarding — an HTTP 200 carrying a status field in the body works
just as well as a 4xx with an error code. What matters is only that a refusal is
distinguishable from a failure.
Three things not to do:
-
Don't return a transport-level failure for a business refusal. A
500or a dropped connection for what is really "insufficient funds" turns a definite no into a maybe. Phygitals then sends arollbackfor a charge you never took. - Don't return success for a call you did not apply. Success is read as money moved. The player is shown a balance you never wrote, and the gap is silent — nothing retries a call that reported success.
- Don't hang. A wallet call that does not answer promptly is treated as unknown, with all the reversal traffic that implies. Refuse fast rather than block.
Retries
A failed credit, rollback or recordAsset is not dropped. Phygitals records it, retries it on a
backoff, and keeps retrying until it lands or the attempt budget is exhausted — always with the same
transaction_id.
| Operation | On failure |
|---|---|
debit |
Never retried. An unknown outcome gets a rollback instead. |
credit |
Retried — the player is already owed the money. |
rollback |
Retried — the charge is already known to be void. |
recordAsset |
Retried — the card has already shipped. |
The asymmetry is the whole design: a debit's failure is safe to abandon (the player just didn't get their pack), while a credit's failure means somebody is out of pocket until it lands. Which is why idempotency isn't optional — every one of those retries is a byte-identical repeat of a call you may already have applied.
Amounts and currency
-
Amounts cross the wire as integers in your currency's minor units —
5800is $58.00. Balances come back the same way. - They are always positive. The operation names the direction; nothing is ever negative.
- If you scope players by currency, tell us how it's encoded on the player id and we send the matching currency on each transaction. Phygitals does not convert between currencies — we send whatever currency the player is in.
Authentication
You hold two credentials, one per direction.
| Credential | Direction | You use it to |
|---|---|---|
X-API-Key |
You → Phygitals | Authenticate your launch call |
| RSA public key | Phygitals → You | Verify the signature on every wallet call |
Launch key
A Phygitals-issued key, sent as X-API-Key on launch. Tied to your partner account and
mode-tagged — separate keys for sandbox and live. Stored by Phygitals as a hash; the secret is
shown once, at creation. Lost it? Revoke and mint a new one.
Call launch from your backend only. The key must never reach a browser, the embedded page, or
your client bundle. The browser only ever sees the resulting launch_url.
Your player token
If you send token on launch, Phygitals stores it — encrypted, keyed to (partner, player, mode).
It has to outlive the iframe. A player can close the tab holding cards they never decided on; about an hour later Phygitals auto-sells those back and has to credit you for them, with no live session left to supply the token.
| Scope | Live sessions only. A sandbox launch stores nothing. |
| Retention | Only while that player has something outstanding — undecided cards, or a wallet call still being retried. |
| Erasure | Cleared as soon as nothing is outstanding and their session window has lapsed. |
| Handling | Encrypted at rest, and redacted from logs. |
Sending token is optional. If your wallet doesn't authorize per session, omit it and Phygitals
stores nothing.
Verifying wallet calls
Every wallet call carries an X-API-Signature header: a base64 RSA-SHA256 signature over the exact
request body, signed by Phygitals. Verify it with the public key we issue at onboarding, and reject
anything that fails.
import { createVerify } from "node:crypto";
// `rawBody` must be the exact bytes received — capture it before
// any JSON body-parser middleware touches the stream.
function verify(rawBody: Buffer, signature: string): boolean {
return createVerify("RSA-SHA256").update(rawBody).verify(PHYGITALS_PUBLIC_KEY, signature, "base64");
}
Verify against the raw request body bytes, not a re-serialized object. Decoding and re-encoding
JSON can reorder keys or change spacing, and the signature will not match. Most frameworks discard the
raw body by default — capture it first. A missing or invalid signature must be rejected with 401,
and no funds may move.
The launch key authenticates you to us; the signature authenticates us to you. They are separate on purpose: a leaked launch key cannot be used to forge a wallet call, and a stolen wallet payload cannot be replayed into a session.
Embedding
Render the launch_url exactly as returned — the player origin is issued at onboarding and can
change, so never reconstruct the URL or hardcode its host.
<iframe
src="https://…/play?lt=plt_AbC…"
allow="clipboard-write"
style="border: 0; width: 100%; height: 800px;"
></iframe>
Allow-list your origins
The player app sends Content-Security-Policy: frame-ancestors, so only approved parents can embed
it. Send us every origin you'll embed from, production and staging, and we'll add them.
frame-ancestors takes origins — scheme plus host, no path, no trailing slash:
https://app.yourbrand.com. An origin that isn't listed is blocked by the browser, not by us, and the
failure shows as a CSP error in the console.
Game codes
game_code chooses which category the player opens on. Omit it and they land on the full catalog.
That is all it does — the player can switch categories inside the iframe at any time, and the launched
code does not restrict the catalog, the price, or anything else.
The codes are fixed — one per category:
game_code |
Category |
|---|---|
phygitals_pokemon |
Pokémon |
phygitals_one_piece |
One Piece |
phygitals_baseball |
Baseball |
phygitals_football |
Football |
phygitals_basketball |
Basketball |
Sandbox and live
Your launch key is mode-tagged, and the mode rides through the whole session.
| Launch key | Wallet calls go to | Catalog |
|---|---|---|
| Sandbox | Your stage environment | Sandbox packs |
| Live | Your production environment | Live packs |
Credentials are issued per environment. Integrate fully on sandbox — including a deliberately failed credit, so you see a retry arrive — before requesting live keys.