# Get a single activity by ID Source: https://docs.immutable.com/api-reference/activities/get-a-single-activity-by-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/activities/{activity_id} Get a single activity by ID # List all activities Source: https://docs.immutable.com/api-reference/activities/list-all-activities https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/activities List all activities # List history of activities Source: https://docs.immutable.com/api-reference/activities/list-history-of-activities https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/activity-history List activities sorted by updated_at timestamp ascending, useful for time based data replication # List supported chains Source: https://docs.immutable.com/api-reference/chains/list-supported-chains https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains List supported chains # Get collection by contract address Source: https://docs.immutable.com/api-reference/collections/get-collection-by-contract-address https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address} Get collection by contract address # List all collections Source: https://docs.immutable.com/api-reference/collections/list-all-collections https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections List all collections # List collections by NFT owner Source: https://docs.immutable.com/api-reference/collections/list-collections-by-nft-owner https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/accounts/{account_address}/collections List collections by NFT owner account address # Refresh collection metadata Source: https://docs.immutable.com/api-reference/collections/refresh-collection-metadata https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/collections/{contract_address}/refresh-metadata Refresh collection metadata # Refresh collection metadata internal Source: https://docs.immutable.com/api-reference/collections/refresh-collection-metadata-internal https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/internal/chains/{chain_name}/environment/{environment}/collections/{contract_address}/refresh-metadata Refresh collection metadata internal # Sign a crafting payload Source: https://docs.immutable.com/api-reference/crafting/sign-a-crafting-payload https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/crafting/sign Sign a crafting payload # Request erasure of all data for an identity Source: https://docs.immutable.com/api-reference/data/request-erasure-of-all-data-for-an-identity /api-specs/audience.json delete /v1/audience/data Accepts an erasure request for the specified identity. All linked identities are resolved via stored alias mappings and an erasure event is queued for async processing. Provide either anonymousId or userId (not both). Requires the project's secret API key, not the publishable key: this deletes another identity's data on the project's behalf, which a client holding only the (public) publishable key can never safely authorize. # Ingest audience messages Source: https://docs.immutable.com/api-reference/ingest/ingest-audience-messages /api-specs/audience.json post /v1/audience/messages # Get list of metadata attribute filters Source: https://docs.immutable.com/api-reference/metadata-search/get-list-of-metadata-attribute-filters https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/search/filters/{contract_address} Get list of metadata filters # Search NFT stacks Source: https://docs.immutable.com/api-reference/metadata-search/search-nft-stacks https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/search/stacks Search NFT stacks # Search NFTs Source: https://docs.immutable.com/api-reference/metadata-search/search-nfts https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/search/nfts Search NFTs # Get a list of metadata from the given chain Source: https://docs.immutable.com/api-reference/metadata/get-a-list-of-metadata-from-the-given-chain https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/metadata Get a list of metadata from the given chain # Get a list of metadata from the given contract Source: https://docs.immutable.com/api-reference/metadata/get-a-list-of-metadata-from-the-given-contract https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/metadata Get a list of metadata from the given contract # Get metadata by ID Source: https://docs.immutable.com/api-reference/metadata/get-metadata-by-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/metadata/{metadata_id} Get metadata by ID # List NFT stack bundles by stack_id. Response will include Market, Listings & Stack Count information for each stack Source: https://docs.immutable.com/api-reference/metadata/list-nft-stack-bundles-by-stack_id-response-will-include-market-listings-&-stack-count-information-for-each-stack https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/stacks List NFT stack bundles by stack_id. This endpoint functions similarly to `ListMetadataByID` but extends the response to include Market, Listings & Stack Count information for each stack. # Refresh NFT metadata Source: https://docs.immutable.com/api-reference/metadata/refresh-nft-metadata https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/collections/{contract_address}/nfts/refresh-metadata Refresh NFT metadata # Refresh stacked metadata Source: https://docs.immutable.com/api-reference/metadata/refresh-stacked-metadata https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/collections/{contract_address}/metadata/refresh-metadata Refresh stacked metadata # List all NFT owners Source: https://docs.immutable.com/api-reference/nft-owners/list-all-nft-owners https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/nft-owners List all NFT owners on a chain # List NFT owners by token ID Source: https://docs.immutable.com/api-reference/nft-owners/list-nft-owners-by-token-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts/{token_id}/owners List NFT owners by token ID # List owners by contract address Source: https://docs.immutable.com/api-reference/nft-owners/list-owners-by-contract-address https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/owners List owners by contract address # Get mint request by reference ID Source: https://docs.immutable.com/api-reference/nfts/get-mint-request-by-reference-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests/{reference_id} Retrieve the status of a mint request identified by its reference_id # Get NFT by token ID Source: https://docs.immutable.com/api-reference/nfts/get-nft-by-token-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts/{token_id} Get NFT by token ID # List all NFTs Source: https://docs.immutable.com/api-reference/nfts/list-all-nfts https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/nfts List all NFTs on a chain # List mint requests Source: https://docs.immutable.com/api-reference/nfts/list-mint-requests https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests Retrieve the status of all mints for a given contract address # List NFTs by account address Source: https://docs.immutable.com/api-reference/nfts/list-nfts-by-account-address https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/accounts/{account_address}/nfts List NFTs by account address # List NFTs by contract address Source: https://docs.immutable.com/api-reference/nfts/list-nfts-by-contract-address https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts List NFTs by contract address # Mint NFTs Source: https://docs.immutable.com/api-reference/nfts/mint-nfts https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests Create a mint request to mint a set of NFTs for a given collection # Cancel one or more orders Source: https://docs.immutable.com/api-reference/orders/cancel-one-or-more-orders https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/cancel Cancel one or more orders # Create a bid Source: https://docs.immutable.com/api-reference/orders/create-a-bid https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/bids Create a bid # Create a collection bid Source: https://docs.immutable.com/api-reference/orders/create-a-collection-bid https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/collection-bids Create a collection bid # Create a listing Source: https://docs.immutable.com/api-reference/orders/create-a-listing https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/listings Create a listing # Create a metadata bid Source: https://docs.immutable.com/api-reference/orders/create-a-metadata-bid https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/metadata-bids Create a metadata bid on NFTs matching a specific metadata identifier within a collection # Create a trait bid Source: https://docs.immutable.com/api-reference/orders/create-a-trait-bid https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/trait-bids Create a trait bid on NFTs matching specific trait criteria within a collection # Get a single bid by ID Source: https://docs.immutable.com/api-reference/orders/get-a-single-bid-by-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/bids/{bid_id} Get a single bid by ID # Get a single collection bid by ID Source: https://docs.immutable.com/api-reference/orders/get-a-single-collection-bid-by-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/collection-bids/{collection_bid_id} Get a single collection bid by ID # Get a single listing by ID Source: https://docs.immutable.com/api-reference/orders/get-a-single-listing-by-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/listings/{listing_id} Get a single listing by ID # Get a single metadata bid by ID Source: https://docs.immutable.com/api-reference/orders/get-a-single-metadata-bid-by-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/metadata-bids/{metadata_bid_id} Get a single metadata bid by ID # Get a single trade by ID Source: https://docs.immutable.com/api-reference/orders/get-a-single-trade-by-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/trades/{trade_id} Get a single trade by ID # Get a single trait bid by ID Source: https://docs.immutable.com/api-reference/orders/get-a-single-trait-bid-by-id https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/trait-bids/{trait_bid_id} Get a single trait bid by ID # List all bids Source: https://docs.immutable.com/api-reference/orders/list-all-bids https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/bids List all bids # List all collection bids Source: https://docs.immutable.com/api-reference/orders/list-all-collection-bids https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/collection-bids List all collection bids # List all listings Source: https://docs.immutable.com/api-reference/orders/list-all-listings https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/listings List all listings # List all metadata bids Source: https://docs.immutable.com/api-reference/orders/list-all-metadata-bids https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/metadata-bids List all metadata bids # List all trades Source: https://docs.immutable.com/api-reference/orders/list-all-trades https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/trades List all trades # List all trait bids Source: https://docs.immutable.com/api-reference/orders/list-all-trait-bids https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/trait-bids List all trait bids # Retrieve fulfillment data for orders Source: https://docs.immutable.com/api-reference/orders/retrieve-fulfillment-data-for-orders https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/fulfillment-data Retrieve signed fulfillment data based on the list of order IDs and corresponding fees. # Get all info for a Passport user Source: https://docs.immutable.com/api-reference/passport-profile/get-all-info-for-a-passport-user https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /passport-profile/v1/user/info Get all the info for an authenticated Passport user # Get profile for authenticated user Source: https://docs.immutable.com/api-reference/passport-profile/get-profile-for-authenticated-user https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /passport-profile/v1/profile Get profile for the authenticated user's Passport wallet # Link wallet v2 Source: https://docs.immutable.com/api-reference/passport-profile/link-wallet-v2 https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /passport-profile/v2/linked-wallets Link an external EOA wallet to an Immutable Passport account by providing an EIP-712 signature. # Send phone OTP code for user supplied phone number Source: https://docs.immutable.com/api-reference/passport-profile/send-phone-otp-code-for-user-supplied-phone-number https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /passport-profile/v1/phone-otp Send phone OTP code for user supplied phone number # Update username Source: https://docs.immutable.com/api-reference/passport-profile/update-username https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /passport-profile/v1/username Update username for the authenticated user's Passport wallet # Verify phone OTP code against user phone number Source: https://docs.immutable.com/api-reference/passport-profile/verify-phone-otp-code-against-user-phone-number https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /passport-profile/v1/phone-otp/verify Verify phone OTP code for user supplied phone number # Get Ethereum linked addresses for a user Source: https://docs.immutable.com/api-reference/passport/get-ethereum-linked-addresses-for-a-user https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/passport/users/{user_id}/linked-addresses This API has been deprecated, please use https://docs.immutable.com/zkevm/api/reference/#/operations/getUserInfo instead to get a list of linked addresses. # Get pricing data for a list of stack ids Source: https://docs.immutable.com/api-reference/pricing/get-pricing-data-for-a-list-of-stack-ids https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/quotes/{contract_address}/stacks Get pricing data for a list of stack ids # Get pricing data for a list of token ids Source: https://docs.immutable.com/api-reference/pricing/get-pricing-data-for-a-list-of-token-ids https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/quotes/{contract_address}/nfts pricing data for a list of token ids # Get single ERC20 token Source: https://docs.immutable.com/api-reference/tokens/get-single-erc20-token https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/tokens/{contract_address} Get single ERC20 token # List ERC20 tokens Source: https://docs.immutable.com/api-reference/tokens/list-erc20-tokens https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/tokens List ERC20 tokens # Get tracking consent status Source: https://docs.immutable.com/api-reference/tracking-consent/get-tracking-consent-status /api-specs/audience.json get /v1/audience/tracking-consent # Update tracking consent status Source: https://docs.immutable.com/api-reference/tracking-consent/update-tracking-consent-status /api-specs/audience.json put /v1/audience/tracking-consent # Admin Security Source: https://docs.immutable.com/docs/guides/admin-security Secure wallet management and studio security best practices ## Wallet Management ### Wallet Types | Wallet Type | Purpose | Security Level | | -------------------- | ----------------------------- | ------------------------ | | **Developer Wallet** | Testing, deploying to testnet | Low (hot wallet OK) | | **Deployer Wallet** | Mainnet contract deployment | Medium (hardware wallet) | | **Treasury Wallet** | Holding funds, revenue | High (multisig) | | **Minter Wallet** | Backend minting operations | Medium (server-side) | ### Developer Wallet Setup For local development and testnet: #### MetaMask Setup 1. Install [MetaMask](https://metamask.io) 2. Add Immutable Chain Testnet: * Network Name: `Immutable Testnet` * RPC: `https://rpc.testnet.immutable.com` * Chain ID: `13473` * Symbol: `tIMX` 3. Get test tokens from the [Faucet](https://hub.immutable.com/faucet) #### Hardhat Configuration ```typescript theme={null} // hardhat.config.ts import { HardhatUserConfig } from 'hardhat/config'; const config: HardhatUserConfig = { }; export default config; ``` **Never commit `.env` files to version control.** Add `.env` and `.env.local` to your `.gitignore` file to prevent accidentally exposing private keys or API secrets. ## Production Treasury: Multisig For production funds, use a multisig wallet like [Safe](https://safe.global). ### Why Multisig? * **No single point of failure**: Multiple signatures required * **Team accountability**: Track who approved transactions * **Recovery**: Lost keys don't mean lost funds ### Setting Up Safe 1. Go to [Safe](https://app.safe.global) 2. Connect to Immutable Chain 3. Create a new Safe with: * Multiple owner addresses (team members) * Threshold (e.g., 2 of 3 signatures required) 4. Fund the Safe with IMX for gas ### Transaction Flow ``` Team Member A proposes transaction ↓ Team Member B reviews and signs ↓ Threshold reached → Transaction executes ``` ## Backend Minting Wallet Your backend needs a wallet for minting operations. ### Key Management Options | Option | Pros | Cons | | ------------------------ | ----------------- | ----------------------- | | **Environment Variable** | Simple | Less secure | | **AWS KMS** | Secure, auditable | More complex | | **HashiCorp Vault** | Enterprise-grade | Infrastructure overhead | ### Using AWS KMS with viem ```typescript theme={null} import { KMSClient, SignCommand } from '@aws-sdk/client-kms'; import { createWalletClient, http, type Account } from 'viem'; import { immutableZkEvm } from 'viem/chains'; // Create a KMS-backed account for viem async function createKMSAccount(keyId: string): Promise { const kms = new KMSClient({ region: 'us-east-1' }); return { address: '0x...', // Derive from KMS public key type: 'local', signMessage: async ({ message }) => { const command = new SignCommand({ KeyId: keyId, Message: Buffer.from(message as string), SigningAlgorithm: 'ECDSA_SHA_256', MessageType: 'DIGEST', }); const response = await kms.send(command); // Process and return signature return '0x...' as `0x${string}`; }, signTransaction: async (tx) => { // Sign transaction with KMS return '0x...' as `0x${string}`; }, signTypedData: async (data) => { // Sign typed data with KMS return '0x...' as `0x${string}`; }, }; } // Use with viem const account = await createKMSAccount(process.env.KMS_KEY_ID!); const walletClient = createWalletClient({ account, chain: immutableZkEvm, transport: http(), }); ``` ### Rate Limiting & Monitoring Protect your minting wallet: ```typescript theme={null} import rateLimit from 'express-rate-limit'; const mintLimiter = rateLimit({ windowMs: 60 * 1000, // 1 minute max: 100, // 100 requests per minute message: 'Too many mint requests', }); app.post('/api/mint', mintLimiter, async (req, res) => { // Log all mint operations logger.info('Mint request', { recipient: req.body.address, tokenId: req.body.tokenId, timestamp: new Date(), }); // Process mint... }); ``` ## Security Checklist ### Development * [ ] Use separate wallet for testnet * [ ] Never use mainnet private keys in development * [ ] Clear browser wallet state between projects ### Production * [ ] Treasury in multisig (Safe) * [ ] Deployer wallet on hardware device * [ ] Minting wallet keys in secure storage (KMS/Vault) * [ ] Rate limiting on minting endpoints * [ ] Monitoring and alerts for unusual activity * [ ] Regular key rotation schedule * [ ] Incident response plan documented ## Gas Management ### Estimating Costs ```typescript theme={null} import { createPublicClient, http, formatEther } from 'viem'; import { immutableZkEvm } from 'viem/chains'; const publicClient = createPublicClient({ chain: immutableZkEvm, transport: http(), }); async function estimateGas(to: `0x${string}`, data: `0x${string}`) { const gasEstimate = await publicClient.estimateGas({ to, data }); const gasPrice = await publicClient.getGasPrice(); const maxCost = gasEstimate * gasPrice; console.log(`Max cost: ${formatEther(maxCost)} IMX`); } ``` ### Maintaining Balance Set up alerts when wallet balance drops: ```typescript theme={null} import { createPublicClient, http, formatEther, parseEther } from 'viem'; import { immutableZkEvm } from 'viem/chains'; const publicClient = createPublicClient({ chain: immutableZkEvm, transport: http(), }); async function checkBalance(address: `0x${string}`, threshold: bigint) { const balance = await publicClient.getBalance({ address }); if (balance < threshold) { await sendAlert(`Wallet ${address} balance low: ${formatEther(balance)} IMX`); } } // Run periodically setInterval(() => checkBalance(MINTER_WALLET, parseEther('10')), 60000); ``` ## Funds Recovery ### From Passport Wallet Players control their Passport wallets. Recovery is through their authentication provider (Google, Apple, etc.). ### From Studio Wallets * **Multisig**: Recover with threshold of remaining signers * **Single-sig**: Depends on backup strategy * **KMS**: AWS manages key durability Always test your recovery procedures before you need them. ## Next Steps Deploy contracts from your secure wallet Set up backend minting # API Keys Source: https://docs.immutable.com/docs/guides/advanced-setup/api-keys Manage publishable and secret API keys for your Immutable project ## Key Types | Key Type | Use Case | Security | Example Usage | | ------------------- | ------------------------------ | -------------- | --------------------------------- | | **Publishable Key** | Client-side SDK initialization | Safe to expose | Browser apps, game clients | | **Secret Key** | Server-side operations | Keep private | Backend minting, admin operations | ## Publishable Key ```typescript theme={null} import { Environment } from '@imtbl/config'; const baseConfig = { environment: Environment.SANDBOX, publishableKey: 'pk_imapik-your-publishable-key', }; ``` **Use for:** * SDK initialization * Client-side API calls * [Passport](/docs/products/passport/overview) authentication ## Secret Key ```typescript theme={null} // Server-side only const headers = { 'x-immutable-api-key': process.env.IMX_SECRET_KEY, }; const response = await fetch('https://api.immutable.com/v1/mint', { method: 'POST', headers, body: JSON.stringify(mintRequest), }); ``` **Use for:** * Minting NFTs * Admin operations * Webhook verification * Any operation that modifies on-chain state Never expose your Secret API Key in: * Client-side JavaScript/TypeScript * Mobile app code * Public Git repositories * Browser developer tools Use environment variables on your server. ## Key Rotation Rotate keys in [Hub](https://hub.immutable.com): **Settings** → **API Keys** → **Rotate Key** Old key invalidates immediately Rotate immediately if exposed ## Environment-Specific Keys Each environment has its own keys: | Environment | Publishable Key Prefix | Chain | | ----------- | ----------------------- | ----------------------- | | Sandbox | `pk_imapik-sandbox-...` | Immutable zkEVM Testnet | | Production | `pk_imapik-...` | Immutable zkEVM Mainnet | ## Next Steps Configure authentication clients Invite members and manage roles Use your secret key for minting # Team Management Source: https://docs.immutable.com/docs/guides/advanced-setup/team-management Invite team members and manage permissions in Immutable Hub ## Invite Team Members Invite in [Hub](https://hub.immutable.com): **Settings** → **Team** → **Invite Member** ## Roles and Permissions | Role | Permissions | | ---------- | --------------------------------------------------------------------------------------- | | **Owner** | Full access. Can manage projects, team members, and member invitations. | | **Member** | Same as Owner, except cannot manage team members (e.g. remove members or change roles). | ## Permission Matrix | Action | Owner | Member | | ------------------------------------------------------ | ----- | ------ | | View projects | ✓ | ✓ | | Create and delete projects | ✓ | ✓ | | Deploy contracts | ✓ | ✓ | | Manage API keys | ✓ | ✓ | | Configure [Passport](/docs/products/passport/overview) | ✓ | ✓ | | Invite members | ✓ | ✓ | | Delete invitations | ✓ | ✓ | | Remove team members | ✓ | - | | Change member roles | ✓ | - | ## Audit Logs Audit log tracks: * [Contract](/docs/products/asset-contracts/overview) deployments * API key rotations * Team member changes * [Passport](/docs/products/passport/overview) client modifications View in **Settings** → **Audit Log** ## Organisation Structure ``` Organisation (Your Company) ├── Project A (Game 1) │ ├── Sandbox environment │ └── Production environment ├── Project B (Game 2) │ ├── Sandbox environment │ └── Production environment └── Team Members ├── Owner └── Members ``` ## Best Practices Periodically review team members and remove anyone who no longer needs access. Use Sandbox for development and restrict Production access to senior team members. ## Next Steps Manage API key access Production readiness checklist # Build a Game Source: https://docs.immutable.com/docs/guides/build-a-game To integrate with Immutable Audience, you will need to be a premium partner. Contact us for a free demo and onboarding To leverage Immutable's other products: ### Install SDK npm install Package Manager Plugin installation ### Setup Passport Instant identity and wallets for your users. Configuration and onboarding Sign in with Immutable (via existing social accounts or email) Get wallet address, check balances, send transactions, sign messages ### Assets Issue tradable assets to increase LTV and retention. Deploy ERC-721 (NFT collection) and ERC-20 (currency) contracts JSON schema, attributes, storage options (Immutable Hosted, IPFS, self-hosted) with optional on-chain validation Server-side NFT minting for achievements, rewards, and starter packs ## Next Steps Complete Passport documentation Add funding options for players Enable secondary market trading Explore Unity and Unreal SDKs # Build a Marketplace Source: https://docs.immutable.com/docs/guides/build-a-marketplace **Prerequisites**: Complete [Build a Game](/docs/guides/build-a-game) first to set up Passport, deploy contracts, and mint NFTs. Build an in-game marketplace where players can fund wallets, view inventory, and trade NFTs. ### Wallet Funding Enable players to add tokens to their wallets for trading. Purchase tokens with fiat currency (powered by Transak) Exchange tokens on zkEVM (powered by QuickSwap) Transfer tokens from Ethereum L1 to zkEVM (powered by Squid) ### Display Inventory Show players their NFT collections and assets. Query NFT ownership, metadata, and trading history Filter and search NFTs by attributes and properties blockchain-data package for inventory queries ### Marketplace Trading Enable peer-to-peer NFT trading with the Orderbook. List NFTs for sale with ERC-20 token pricing Purchase listed NFTs from other players Remove NFTs from marketplace Make offers on entire collections Make offers on subsets of a collection using metadata filters Make offers by metadata ID or field-level metadata criteria Configure platform and royalty fees Complete API reference and integration guides ### SDK Implementation Full widget integration for web applications # Immutable Source: https://docs.immutable.com/docs/immutable The growth platform for games—acquire players, automate engagement, and drive revenue ## Why Immutable? Immutable is the growth platform for games: acquire players, automate engagement, and drive revenue across PC, console, and mobile. We turn anonymous store and ad traffic into identified players you can message, measure, and monetize—especially for Steam launches, where wishlists alone do not give you an owned audience. ### Platforms
Steam Epic Games Store PlayStation Xbox App Store Google Play Discord
PC (Steam, Epic) · Console (PlayStation, Xbox) · Mobile (App Store, Google Play) · Community (Discord) ### Convert Capture emails, wishlists, socials, and consent through an optimized funnel. Unify Steam, Discord, and UA traffic into owned identities you can reach again—not anonymous storefront counts. ### Engage Run lifecycle campaigns from signup through launch and beyond. Message players you own instead of relying only on storefront notifications. ### Measure See which channels produce players, not just clicks. Attribute signups and in-game outcomes so you can shift spend to what pays back. ## Core Products Collect, unify, and activate player profiles Measure campaigns on those same profiles Indexer, Orderbook, Checkout, and Asset Contracts Authentication and embedded wallets Player discovery, rewards, and questing ## SDKs Web & backend Unity games Unreal Engine # Crafting Source: https://docs.immutable.com/docs/products/asset-contracts/crafting Let players combine existing NFTs to create new ones—a core mechanic for progression and engagement in games. ## Why Use Crafting? Crafting drives collection and trading behaviour. Players actively hunt for ingredients, creating demand across your economy. Burning inputs removes items from circulation, maintaining scarcity and value in your game economy. Multi-step recipes create meaningful goals. Players feel invested when they craft rare items. ## How It Works Player chooses NFTs to combine from their inventory Your server verifies ownership and recipe validity Transfer input NFTs to burn address Create new NFT for player via [Minting API](/docs/products/asset-contracts/minting-api) ## Recipe System Define recipes that map inputs to outputs: ```typescript theme={null} const recipes = [ { id: 'iron_sword', inputs: [ { contract: MATERIALS, trait: { type: 'Iron Ore' }, count: 2 }, { contract: MATERIALS, trait: { type: 'Wood' }, count: 1 }, ], output: { name: 'Iron Sword', attributes: [{ trait_type: 'Damage', value: 10 }], }, }, { id: 'steel_sword', inputs: [ { contract: WEAPONS, trait: { type: 'Iron Sword' }, count: 1 }, { contract: MATERIALS, trait: { type: 'Steel Ingot' }, count: 2 }, ], output: { name: 'Steel Sword', attributes: [{ trait_type: 'Damage', value: 25 }], }, }, ]; ``` ## Implementation ```typescript theme={null} const BURN_ADDRESS = '0x000000000000000000000000000000000000dEaD'; app.post('/craft', async (req, res) => { const { recipeId, inputTokenIds, playerAddress } = req.body; const recipe = recipes.find(r => r.id === recipeId); if (!recipe) { return res.status(400).json({ error: 'Invalid recipe' }); } // Verify ownership of all inputs for (const tokenId of inputTokenIds) { const owner = await nftContract.ownerOf(tokenId); if (owner.toLowerCase() !== playerAddress.toLowerCase()) { return res.status(403).json({ error: 'Not owner of input' }); } } // Verify inputs match recipe requirements // ... validation logic // Burn inputs for (const tokenId of inputTokenIds) { await nftContract.transferFrom(playerAddress, BURN_ADDRESS, tokenId); } // Mint output via Minting API const outputId = generateTokenId(); await mintNFT(playerAddress, outputId, recipe.output); res.json({ success: true, outputId }); }); ``` ## Upgrades Enhance existing items by burning materials: ```typescript theme={null} async function upgradeItem(tokenId: string, materialsUsed: string[]) { const nft = await getNFT(tokenId); const level = nft.attributes.find(a => a.trait_type === 'Level')?.value || 1; // Burn material NFTs for (const materialId of materialsUsed) { await burnNFT(materialId); } // Update metadata via Minting API refresh await refreshMetadata(tokenId, { ...nft.metadata, attributes: nft.attributes.map(a => a.trait_type === 'Level' ? { ...a, value: level + 1 } : a ), }); } ``` ## Design Tips | Pattern | Description | | ----------------- | -------------------------------------- | | **Deterministic** | Same inputs always produce same output | | **Probabilistic** | Random chance for rare outcomes | | **Time-gated** | Crafting takes time to complete | | **Progressive** | Multi-step recipes for advanced items | Keep crafting server-side to prevent exploitation. Only mint outputs after verifying ownership and burning inputs. ## Next Steps Learn about the Minting API Deploy NFT contracts Use multi-token contracts # Deploy Contracts Source: https://docs.immutable.com/docs/products/asset-contracts/deploy-contracts Deploy smart contracts through Hub without writing code ## Supported Contract Types | Contract | Standard | Use Case | | -------------------- | -------------------------------------------------- | ------------------------------------------------- | | **ImmutableERC721** | [ERC-721](/docs/products/asset-contracts/erc721) | Unique NFTs (characters, items, land) | | **ImmutableERC1155** | [ERC-1155](/docs/products/asset-contracts/erc1155) | Semi-fungible tokens (resources, stackable items) | | **ImmutableERC20** | [ERC-20](/docs/products/asset-contracts/erc20) | Fungible tokens (in-game currency) | ## Deploy via Hub Deploy contracts in [Hub](https://hub.immutable.com): **Contracts** → **Deploy Contract** Contract types: ERC-721, ERC-1155, ERC-20 Gas fees sponsored by Immutable ## Pre-configured Features | Feature | Description | | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | **Minting API Ready** | Works with [Immutable's Minting API](/docs/products/asset-contracts/minting-api) immediately | | **[Operator Allowlist](/docs/products/asset-contracts/operator-allowlist)** | Pre-approved for Immutable marketplace contracts | | **[Royalties](/docs/products/asset-contracts/royalties)** | EIP-2981 royalties configured | | **Gas Sponsorship** | Compatible with Immutable's gas sponsorship | ## After Deployment Set up metadata, enable minting, configure royalties Backend minting requires [Secret API Key](/docs/guides/advanced-setup/api-keys) NFT contracts Multi-token contracts Programmatic minting Token metadata ## Deploy via Code ```typescript theme={null} import { ImmutableERC721 } from '@imtbl/contracts'; // Deploy using Hardhat or your preferred framework const contract = await ImmutableERC721.deploy( owner, name, symbol, baseURI, contractURI, operatorAllowlist, royaltyReceiver, royaltyPercentage ); ``` Learn more about custom contract deployment # Deploy Contracts with Hardhat Source: https://docs.immutable.com/docs/products/asset-contracts/deploy-contracts-with-hardhat Deploy smart contracts to Immutable zkEVM using Hardhat, a popular Ethereum development environment. **Use Immutable Contract Presets**: Immutable provides audited, production-ready contract presets that include built-in royalty enforcement, operator allowlists, and metadata management. See the [ERC-721](/docs/products/asset-contracts/erc721), [ERC-1155](/docs/products/asset-contracts/erc1155), and [ERC-20](/docs/products/asset-contracts/erc20) preset documentation for details. ## Immutable zkEVM Collection Requirements All NFT collections deployed on Immutable zkEVM must implement: * **Royalty Enforcement**: EIP-2981 royalty standard with on-chain enforcement via the Operator Allowlist * **Operator Allowlist**: Restrict transfers to approved marketplaces and platforms * **Metadata Standards**: ERC-721/ERC-1155 metadata standards with proper token/collection URIs Immutable's preset contracts handle these requirements automatically. If using custom contracts, ensure they meet these standards. See the [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist) and [Royalties](/docs/products/asset-contracts/royalties) documentation for detailed implementation guidance. ## Setup Hardhat Install and configure Hardhat following the [official Hardhat setup guide](https://hardhat.org/hardhat-runner/docs/getting-started#installation). Install the toolbox and `dotenv` packages: ```bash theme={null} npm install --save-dev @nomicfoundation/hardhat-toolbox dotenv ``` ## Configure Hardhat To deploy contracts to Immutable zkEVM, configure your `hardhat.config.ts` file with network settings: ```typescript theme={null} import { HardhatUserConfig } from "hardhat/config"; import "@nomicfoundation/hardhat-toolbox"; import * as dotenv from "dotenv"; dotenv.config(); const config: HardhatUserConfig = { solidity: { version: "0.8.19", settings: { optimizer: { enabled: true, runs: 200, }, }, }, }; export default config; ``` **Network Details:** | Network | RPC URL | Chain ID | | ----------- | ----------------------------------- | -------- | | **Testnet** | `https://rpc.testnet.immutable.com` | 13473 | | **Mainnet** | `https://rpc.immutable.com` | 13371 | The deployer account must have sufficient IMX for gas fees. Get testnet IMX from the [Immutable Testnet Faucet](https://portal.immutable.com/faucet). ### Environment Variables Create a `.env` file in your project root: ```bash theme={null} PRIVATE_KEY=your_wallet_private_key_here ``` **Never commit `.env` files to version control.** Add `.env` and `.env.local` to your `.gitignore` file to prevent accidentally exposing private keys or API secrets. ## Add Contract After installing the preset contract library (`npm install @imtbl/contracts`), create a `contracts` directory and add your contract file. **Solidity Version**: The preset contracts use Solidity `0.8.19`. Ensure your `hardhat.config.ts` has `version: "0.8.19"` in the `solidity` section. **Operator Allowlist Addresses** (required constructor parameter): | Network | Operator Allowlist Address | | ----------- | -------------------------------------------- | | **Testnet** | `0x6b969FD89dE634d8DE3271EbE97734FEFfcd58eE` | | **Mainnet** | `0x1B16F1Da2E5DF531512E15F68c86ac0A7C2a6929` | The Operator Allowlist restricts NFT transfers to approved marketplaces. For details on how it works and how to manage the allowlist, see [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist). Create `contracts/MyERC721.sol`: ```solidity theme={null} // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@imtbl/contracts/contracts/token/erc721/preset/ImmutableERC721.sol"; contract MyERC721 is ImmutableERC721 { constructor( address owner_, string memory name_, string memory symbol_, string memory baseURI_, string memory contractURI_, address operatorAllowlist_, address royaltyReceiver_, uint96 feeNumerator_ ) ImmutableERC721( owner_, name_, symbol_, baseURI_, contractURI_, operatorAllowlist_, royaltyReceiver_, feeNumerator_ ) {} } ``` Create `contracts/MyERC1155.sol`: ```solidity theme={null} // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@imtbl/contracts/contracts/token/erc1155/preset/ImmutableERC1155.sol"; contract MyERC1155 is ImmutableERC1155 { constructor( address owner_, string memory name_, string memory baseURI_, string memory contractURI_, address operatorAllowlist_, address royaltyReceiver_, uint96 feeNumerator_ ) ImmutableERC1155( owner_, name_, baseURI_, contractURI_, operatorAllowlist_, royaltyReceiver_, feeNumerator_ ) {} } ``` ## Compile Compile your contracts to generate artifacts and type definitions: ```bash theme={null} npx hardhat compile ``` **Output:** ``` Compiling... Compiled 1 contract successfully ``` This generates: * Contract artifacts in `artifacts/` * ABI files for contract interaction ## Test Write tests in the `test/` directory using Hardhat's testing framework (Mocha and Chai): ```typescript theme={null} import { expect } from "chai"; import { ethers } from "hardhat"; describe("MyERC721", function () { it("Should deploy with correct name and symbol", async function () { const [owner] = await ethers.getSigners(); const MyERC721 = await ethers.getContractFactory("MyERC721"); const contract = await MyERC721.deploy( owner.address, "My Collection", "MYC", "https://example.com/metadata/", "https://example.com/collection.json", "0x6b969FD89dE634d8DE3271EbE97734FEFfcd58eE", // Testnet allowlist owner.address, 200 // 2% royalty ); await contract.waitForDeployment(); expect(await contract.name()).to.equal("My Collection"); expect(await contract.symbol()).to.equal("MYC"); }); }); ``` Run tests: ```bash theme={null} npx hardhat test ``` Hardhat automatically compiles contracts before running tests, so `npx hardhat compile` is not required before testing. ## Deploy ### Write Deployment Script **Important**: Immutable zkEVM requires specific gas configuration. Always include transaction overrides with `maxFeePerGas`, `maxPriorityFeePerGas`, and `gasLimit`. Create `scripts/deploy.ts`: ```typescript theme={null} import { ethers } from "hardhat"; async function main() { const [deployer] = await ethers.getSigners(); console.log("Deploying contracts with account:", deployer.address); console.log("Account balance:", (await ethers.provider.getBalance(deployer.address)).toString()); const MyERC721 = await ethers.getContractFactory("MyERC721"); const contract = await MyERC721.connect(deployer).deploy( deployer.address, // owner "My Collection", // name "MYC", // symbol "https://example.com/metadata/", // baseURI "https://example.com/collection.json", // contractURI "0x6b969FD89dE634d8DE3271EbE97734FEFfcd58eE", // operatorAllowlist (testnet) deployer.address, // royaltyReceiver 200, // feeNumerator (2%) { maxPriorityFeePerGas: 10e9, // 10 gwei maxFeePerGas: 15e9, // 15 gwei gasLimit: 200000, // Set an appropriate gas limit for your transaction } ); await contract.waitForDeployment(); const address = await contract.getAddress(); console.log("Contract deployed to:", address); console.log("Transaction hash:", contract.deploymentTransaction()?.hash); } main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); }); ``` ```typescript theme={null} import { ethers } from "hardhat"; async function main() { const [deployer] = await ethers.getSigners(); console.log("Deploying contracts with account:", deployer.address); console.log("Account balance:", (await ethers.provider.getBalance(deployer.address)).toString()); const MyERC1155 = await ethers.getContractFactory("MyERC1155"); const contract = await MyERC1155.connect(deployer).deploy( deployer.address, // owner "My Collection", // name "https://example.com/metadata/", // baseURI "https://example.com/collection.json", // contractURI "0x6b969FD89dE634d8DE3271EbE97734FEFfcd58eE", // operatorAllowlist (testnet) deployer.address, // royaltyReceiver 200, // feeNumerator (2%) { maxPriorityFeePerGas: 10e9, // 10 gwei maxFeePerGas: 15e9, // 15 gwei gasLimit: 200000, // Set an appropriate gas limit for your transaction } ); await contract.waitForDeployment(); const address = await contract.getAddress(); console.log("Contract deployed to:", address); console.log("Transaction hash:", contract.deploymentTransaction()?.hash); } main() .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); }); ``` **Constructor Parameters:** * `owner`: Address with admin rights (typically deployer) * `name`: Collection name (e.g., "My NFT Collection") * `symbol`: Token symbol for ERC-721 (e.g., "MYC") * `baseURI`: Base URI for token metadata (e.g., "[https://api.example.com/metadata/](https://api.example.com/metadata/)") * `contractURI`: Collection-level metadata URI * `operatorAllowlist`: Address of the Operator Allowlist contract (see table above) * `royaltyReceiver`: Address that receives royalty payments * `feeNumerator`: Royalty percentage in basis points (200 = 2%, 1000 = 10%) ### Deploy to Testnet Deploy your contract to Immutable zkEVM testnet: ```bash theme={null} npx hardhat run scripts/deploy.ts --network immutableZkevmTestnet ``` **Expected output:** ``` Deploying contracts with account: 0x1234... Account balance: 1000000000000000000 Contract deployed to: 0x96dBDB46eCeEFd7082AE6461A83A6f08C8F5cd1C Transaction hash: 0x5678... ``` Save the deployed contract address for the next steps. ### Deploy to Mainnet **Production Deployment**: Test thoroughly on testnet before deploying to mainnet. Ensure: * Contract has been audited if using custom logic * Deployer account has sufficient IMX for gas fees * All parameters (metadata URIs, allowlist address, royalty settings) are correct * **Mainnet Operator Allowlist address**: `0x1B16F1Da2E5DF531512E15F68c86ac0A7C2a6929` ```bash theme={null} npx hardhat run scripts/deploy.ts --network immutableZkevmMainnet ``` ## Gas Pricing Immutable zkEVM enforces a minimum gas price of 10 gwei to protect against spam traffic and ensure efficient transaction processing. ### Minimum Requirements * **Minimum Gas Price**: Transactions with a tip cap below 10 gwei are rejected by the RPC * **Fee Cap**: Must be greater than or equal to 10 gwei * **Base Fee**: Transactions only mine when the gas fee cap is greater than or equal to the base fee The base fee is generally expected to stay below the minimum gas tip cap. ### Recommended Gas Configuration When deploying contracts, use the following gas settings: ```typescript theme={null} { maxPriorityFeePerGas: 10e9, // 10 gwei (minimum) maxFeePerGas: 15e9, // 15 gwei gasLimit: 200000, // Set an appropriate gas limit for your transaction } ``` **Gas Parameters:** * `maxPriorityFeePerGas`: Priority fee (tip) you're willing to pay to miners (minimum 10 gwei) * `maxFeePerGas`: Maximum total fee per gas unit (base fee + priority fee) * `gasLimit`: Maximum gas units the transaction can consume (set higher for contract deployments) For advanced gas price optimization, use the [Ethereum RPC specification](https://ethereum.github.io/execution-apis/api-documentation/) `eth_feeHistory` method to analyze recent gas prices and adjust accordingly. ## Troubleshooting ### UNPREDICTABLE\_GAS\_LIMIT Error **Error message:** ```bash theme={null} reason: 'cannot estimate gas; transaction may fail or may require manual gas limit', code: 'UNPREDICTABLE_GAS_LIMIT', error: Error: gas required exceeds allowance or always failing transaction ``` **Causes:** 1. **Incorrect constructor arguments**: Verify all parameters are correct (especially addresses) 2. **Insufficient gas limit**: The default limit may be too low for complex contracts 3. **Contract logic error**: The contract may revert due to a bug **Solutions:** 1. **Verify constructor arguments**: Double-check all addresses, strings, and numbers 2. **Estimate and increase gas limit**: First estimate the required gas, then set `gasLimit` accordingly: ```typescript theme={null} // Estimate gas to determine required limit const estimatedGas = await MyERC721.getDeployTransaction(...constructorArgs).then(tx => ethers.provider.estimateGas(tx) ); console.log("Estimated gas:", estimatedGas.toString()); // Set gasLimit higher than estimate (add ~20% buffer) const gasOverrides = { maxPriorityFeePerGas: 10e9, // 10 gwei maxFeePerGas: 15e9, // 15 gwei gasLimit: Math.ceil(Number(estimatedGas) * 1.2), }; ``` 3. **Test locally**: Run `npx hardhat test` to catch errors before deployment ## Link Contract to Immutable Hub After deployment, link your contract via [Immutable Hub](https://hub.immutable.com) to enable metadata management, analytics, and Minting API support. ## Minting API Prerequisites If your contract will use the [Minting API](/docs/products/asset-contracts/minting-api) for programmatic minting, grant the Minting API the required `MINTER_ROLE`: ```typescript theme={null} // Get the Minting API address from Hub (under Contract > Minting API section) const MINTING_API_ADDRESS = "0x..."; // From Hub // Grant minter role const MINTER_ROLE = await contract.MINTER_ROLE(); await contract.grantRole(MINTER_ROLE, MINTING_API_ADDRESS); console.log("Minter role granted to Minting API"); ``` **Preset Compatibility**: The Minting API has been rigorously tested with Immutable's preset contracts. If using custom contracts, thoroughly test compatibility. Immutable provides no compatibility guarantees for custom implementations. See the [Minting API documentation](/docs/products/asset-contracts/minting-api) for complete setup instructions. ## Contract Verification Verify your contract on Immutable Explorer to receive a green checkmark indicating legitimacy. Go to your contract on [Immutable Explorer](https://explorer.immutable.com): * **Testnet**: `https://explorer.testnet.immutable.com/address/YOUR_CONTRACT_ADDRESS` * **Mainnet**: `https://explorer.immutable.com/address/YOUR_CONTRACT_ADDRESS` Select the verification method (via source code or compiler settings). Provide your contract source code and match the compiler settings from your `hardhat.config.ts`. Once verified, your contract displays a green checkmark and users can read the source code directly on Explorer. Verified contracts allow users to interact with contract functions directly through the Explorer UI, improving transparency and trust. ## Next Steps Send transactions and interact with your deployed contracts using Passport Mint NFTs programmatically using Immutable's Minting API Configure token and collection metadata for your NFTs Understand how royalties work and how to configure them # ERC-1155 (Multi-Tokens) Source: https://docs.immutable.com/docs/products/asset-contracts/erc1155 ERC-1155 tokens support both unique and stackable assets in one contract. Multiple copies of an item share the same token ID, making them ideal for consumables, crafting materials, and editions. ## Why Use ERC-1155? Transfer multiple token types in a single transaction. Perfect for loot drops, rewards, and crafting systems. Mix fungible (stackable) and non-fungible (unique) tokens in one contract. No need for multiple contracts. More efficient storage and batch operations mean lower costs for games with many item types. Players can hold 100 health potions as one balance entry, not 100 separate NFTs. ## Use Cases * **Consumables**: Potions, ammo, food * **Crafting Materials**: Ore, wood, gems * **Editions**: Limited prints of artwork * **Loot Boxes**: Reward bundles * **Event Tickets**: Time-limited access tokens ## Deploy via Hub Deploy ERC-1155 contracts in Hub ## Metadata ```json theme={null} { "name": "Health Potion", "description": "Restores 50 HP", "image": "https://assets.example.com/potion.png", "attributes": [ { "trait_type": "Type", "value": "Consumable" }, { "trait_type": "Healing", "display_type": "number", "value": 50 } ] } ``` ## Minting ### Via [Minting API](/docs/products/asset-contracts/minting-api) ```bash theme={null} curl -X POST '{baseURL}/v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests' \ -H 'x-immutable-api-key: YOUR_SECRET_KEY' \ -H 'Content-Type: application/json' \ -d '{ "assets": [{ "owner_address": "0xRecipient", "token_id": "1", "amount": "10", "metadata": { "name": "Health Potion", "image": "https://assets.example.com/potion.png" } }] }' ``` ### Batch Different Token Types ```typescript theme={null} const assets = [ { owner_address: player, token_id: "1", amount: "5", metadata: healthPotion }, { owner_address: player, token_id: "2", amount: "3", metadata: manaPotion }, { owner_address: player, token_id: "3", amount: "10", metadata: ironOre }, ]; await fetch(`${API_URL}/collections/${contract}/nfts/mint-requests`, { method: 'POST', headers: { 'x-immutable-api-key': secretKey }, body: JSON.stringify({ assets }), }); ``` ## Fungible vs Non-Fungible Patterns ### Fungible (Stackable) All health potions share `token_id: "1"` and are interchangeable: ```typescript theme={null} // Mint 100 health potions await mint({ token_id: "1", amount: "100", metadata: healthPotion }); // Player transfers 10 to friend await contract.safeTransferFrom(player, friend, "1", 10, "0x"); ``` ### Non-Fungible (Unique) Each legendary weapon gets a unique ID: ```typescript theme={null} // Mint unique weapons await mint({ token_id: "1001", amount: "1", metadata: uniqueSword }); await mint({ token_id: "1002", amount: "1", metadata: uniqueShield }); ``` ## Common Patterns ### Loot Box Rewards ```typescript theme={null} async function openLootBox(playerAddress: string) { const rewards = generateRandomRewards(); const assets = rewards.map((reward, i) => ({ owner_address: playerAddress, token_id: reward.tokenId, amount: String(reward.quantity), })); await mintBatch(assets); } ``` ## Deploy via Code ```solidity theme={null} // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@imtbl/contracts/contracts/token/erc1155/preset/ImmutableERC1155.sol"; contract MyGameItems is ImmutableERC1155 { constructor( address owner, string memory name, string memory baseURI, string memory contractURI, address operatorAllowlist, address royaltyReceiver, uint96 royaltyFeeNumerator ) ImmutableERC1155( owner, name, baseURI, contractURI, operatorAllowlist, royaltyReceiver, royaltyFeeNumerator ) {} } ``` ## ERC-721 vs ERC-1155 | Feature | ERC-721 | ERC-1155 | | ------------------ | ---------------- | ---------------------- | | **Token Type** | Always unique | Unique or stackable | | **Gas Efficiency** | Higher per token | Lower for batches | | **Use Case** | Characters, land | Materials, consumables | | **Complexity** | Simpler | More flexible | ## Next Steps Mint tokens via API Deploy via Hub Explore contract options Compare with ERC-721 # ERC-20 (In-Game Currencies) Source: https://docs.immutable.com/docs/products/asset-contracts/erc20 ERC-20 tokens are fungible—each unit is identical and interchangeable. Use them for in-game currencies, reward points, governance tokens, or any divisible asset. ## Why Use ERC-20? Players own their currency on-chain. They keep it even if they stop playing your game. List on DEXes or enable peer-to-peer trading. Players can buy/sell currency with real market prices. Same token can work across multiple games. Build partnerships and shared economies. All supply, distribution, and transactions are verifiable on-chain. Players trust what they can verify. ## Deploy via Hub Deploy ERC-20 contracts in Hub ## Deploy via Code ```solidity theme={null} // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import "@openzeppelin/contracts/access/Ownable.sol"; contract GameCurrency is ERC20, Ownable { constructor() ERC20("Gold Coins", "GOLD") Ownable(msg.sender) { _mint(msg.sender, 1000000 * 10 ** decimals()); } function mint(address to, uint256 amount) external onlyOwner { _mint(to, amount); } } ``` ## Distribution Patterns ### Achievement Rewards ```typescript theme={null} const REWARDS = { 'first_kill': 100n * 10n ** 18n, 'level_10': 500n * 10n ** 18n, 'boss_defeated': 1000n * 10n ** 18n, }; async function grantAchievement(player: string, achievement: string) { const reward = REWARDS[achievement]; if (reward) await currencyContract.mint(player, reward); } ``` ### Daily Login ```typescript theme={null} async function claimDailyReward(player: string) { const lastClaim = await getLastClaimTime(player); if (Date.now() - lastClaim < 24 * 60 * 60 * 1000) { throw new Error('Already claimed today'); } await currencyContract.mint(player, 50n * 10n ** 18n); await setLastClaimTime(player, Date.now()); } ``` ## Spending Currency ### In-Game Purchases ```typescript theme={null} async function purchaseItem(player: string, itemId: string, price: bigint) { // Transfer from player to treasury await currencyContract.transferFrom(player, TREASURY, price); // Grant the item await grantItem(player, itemId); } ``` ### Approval Flow Players must approve your contract to spend their tokens: ```typescript theme={null} // Frontend: Request approval async function approveSpending(amount: bigint) { const signer = await ethersProvider.getSigner(); const currency = new ethers.Contract(CURRENCY_ADDRESS, ERC20_ABI, signer); await currency.approve(GAME_CONTRACT, amount); } ``` ## Balance Queries ```typescript theme={null} // Direct contract call const balance = await currencyContract.balanceOf(playerAddress); console.log(`${ethers.formatEther(balance)} GOLD`); // Via Indexer API const tokens = await indexer.listTokensByAccountAddress({ chainName: 'imtbl-zkevm-mainnet', accountAddress: playerAddress, }); ``` See [Indexer API](/docs/products/indexer/overview) for querying token balances. ## Economy Design * Set maximum supply caps * Implement burning mechanics (consumables, fees) * Balance earn rates with spending sinks * **Soft currency**: Earned through gameplay, unlimited * **Hard currency**: Purchased or rare, limited supply * **Seasonal currency**: Resets each season * Rate limit claims per wallet * Require game actions, not just API calls * Monitor for suspicious patterns ## Next Steps Deploy via Hub Query token balances Explore other contract types Deploy programmatically # ERC-721 (NFTs) Source: https://docs.immutable.com/docs/products/asset-contracts/erc721 ERC-721 tokens are unique, non-fungible assets—each has a distinct ID and metadata. Use them for characters, weapons, land, collectibles, or any asset that should be one-of-a-kind. ## Why Use ERC-721? Players own their assets on-chain. They can trade, sell, or use them across any compatible game or marketplace. Fixed supply is enforced by the blockchain. Players can verify rarity and authenticity of any item. Earn royalties on every resale. Set your percentage at deployment and receive automatic payments. Standard ERC-721 works with all Ethereum wallets, marketplaces, and tools out of the box. ## Features * **Unique Token IDs**: Each token has distinct metadata * **EIP-2981 Royalties**: Automatic royalty enforcement * **Operator Allowlist**: Control which contracts can transfer * **Batch Minting**: Create multiple tokens in one transaction * **[Minting API](/docs/products/asset-contracts/minting-api)**: Server-side minting at scale ## Deploy via Hub Deploy ERC-721 contracts in Hub ## Metadata Each NFT needs metadata following the ERC-721 standard. Metadata defines how items appear in wallets, marketplaces, and your game. ### Required Fields ```json theme={null} { "name": "Legendary Sword", "description": "A powerful weapon forged in ancient fires", "image": "https://assets.example.com/sword.png" } ``` | Field | Required | Description | | ------------- | -------- | ----------------------------------------- | | `name` | Yes | Item's display name | | `description` | Yes | Short description of the item | | `image` | Yes | URL to item image (PNG, WEBP recommended) | ### Optional Fields | Field | Description | | ------------------ | ------------------------------------------------- | | `animation_url` | URL to video, audio, or 3D asset (MP4, WEBM, GLB) | | `external_url` | Link to item details on your website | | `background_color` | Six-character hex (without #) for item background | | `attributes` | Array of traits for filtering and display | ### Attributes Use `attributes` to define item properties visible in marketplaces: ```json theme={null} { "name": "Flame Blade", "description": "A legendary sword imbued with fire magic", "image": "https://assets.example.com/flame-blade.png", "attributes": [ { "trait_type": "Rarity", "value": "Legendary" }, { "trait_type": "Damage", "display_type": "number", "value": 150 }, { "trait_type": "Element", "value": "Fire" }, { "trait_type": "Level Requirement", "display_type": "number", "value": 25 } ] } ``` ### Attribute Display Types Control how attributes appear in wallets and marketplaces: | Display Type | Use For | Example Value | How It Displays | | ------------------ | ------------------ | ------------- | --------------------- | | (none) | Text values | `"Legendary"` | Rarity: Legendary | | `number` | Numeric stats | `150` | Damage: 150 | | `boost_percentage` | Percentage bonuses | `10` | Fire Resistance: +10% | | `boost_number` | Numeric bonuses | `5` | Speed: +5 | | `date` | Timestamps | `1672531200` | Minted: Jan 1, 2023 | **Naming consistency:** Use the same `trait_type` across all items. "Rarity" everywhere, not "Rarity" for some items and "Tier" for others. This improves marketplace filtering. ### Metadata Hosting Options #### Immutable Hosted (Recommended) Include metadata directly in mint requests. Immutable stores and serves it automatically: ```json theme={null} POST {baseURL}/v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests { "assets": [{ "owner_address": "0xRecipient", "token_id": "1", "metadata": { "name": "Epic Sword", "image": "https://assets.example.com/sword.png", "attributes": [{ "trait_type": "Damage", "value": 100 }] } }] } ``` | Environment | Base URL | | ----------- | ----------------------------------- | | **Testnet** | `https://api.sandbox.immutable.com` | | **Mainnet** | `https://api.immutable.com` | **Pros:** * No hosting infrastructure required * Automatic IPFS pinning for permanence * Included in Minting API workflow **Cons:** * Metadata becomes immutable after minting * Images still need external hosting #### IPFS (Decentralized) Upload metadata JSON to IPFS for permanent, decentralized storage: **Pros:** * Fully decentralized and permanent * Content-addressed (tamper-proof) * No ongoing hosting costs **Cons:** * Requires IPFS setup or pinning service (Pinata, NFT.Storage) * Metadata cannot be updated #### Self-Hosted Host metadata on your own servers. Set `baseURI` during contract deployment: **Pros:** * Full control over metadata * Can update metadata after minting * Dynamic attributes possible **Cons:** * Hosting infrastructure required * Centralization (server downtime affects metadata) * Must maintain availability ## Minting ### Via [Minting API](/docs/products/asset-contracts/minting-api) Enable Minting API access in [Hub](/docs/products/asset-contracts/deploy-contracts), then: ```bash theme={null} curl -X POST '{baseURL}/v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests' \ -H 'x-immutable-api-key: YOUR_SECRET_KEY' \ -H 'Content-Type: application/json' \ -d '{ "assets": [{ "owner_address": "0xRecipient", "token_id": "1", "metadata": { "name": "Epic Sword", "image": "https://assets.example.com/sword.png", "attributes": [{ "trait_type": "Damage", "value": 100 }] } }] }' ``` ### Batch Minting Mint up to 100 tokens per request: ```typescript theme={null} const assets = Array.from({ length: 100 }, (_, i) => ({ owner_address: recipientAddress, token_id: String(i + 1), metadata: { name: `Item #${i + 1}`, image: `https://assets.example.com/${i + 1}.png`, }, })); await fetch(`${API_URL}/collections/${contract}/nfts/mint-requests`, { method: 'POST', headers: { 'x-immutable-api-key': secretKey }, body: JSON.stringify({ assets }), }); ``` ## Deploy via Code ```solidity theme={null} // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "@imtbl/contracts/contracts/token/erc721/preset/ImmutableERC721.sol"; contract MyGameNFT is ImmutableERC721 { constructor( address owner, string memory name, string memory symbol, string memory baseURI, string memory contractURI, address operatorAllowlist, address royaltyReceiver, uint96 royaltyFeeNumerator ) ImmutableERC721( owner, name, symbol, baseURI, contractURI, operatorAllowlist, royaltyReceiver, royaltyFeeNumerator ) {} } ``` ## Next Steps Sell NFTs directly to players Let players combine items # Minting API Source: https://docs.immutable.com/docs/products/asset-contracts/minting-api Mint NFTs at scale without managing nonces, batching, gas, or indexing—everything is handled through a single API call. ## Why Use the Minting API? | Feature | Benefit | | -------------------------- | --------------------------------------------------------------- | | **Managed Infrastructure** | No nonce tracking, transaction lifecycle, or gas management | | **Batch Optimization** | Multiple mint requests combined into gas-efficient transactions | | **Metadata Indexing** | Metadata indexed immediately without crawling your `baseURI` | | **Rate Limit Handling** | Built-in queuing with clear rate limit feedback | | **Idempotent Requests** | Safe to retry with same `reference_id` | The Minting API is a REST API. See the endpoint examples below for request/response formats. ## Prerequisites ERC-721 or ERC-1155 contract Grant minter role to Immutable Secret API Key for authentication The Minting API requires [Immutable preset contracts](/docs/products/asset-contracts/overview). Custom contracts are not supported. **Security:** Keep your Secret API Key server-side only. See [API Keys Security](/docs/guides/advanced-setup/api-keys#secret-key) for best practices. ## Rate Limits | Tier | NFTs/SFTs per minute | Burst | Use Case | | -------------- | -------------------- | ------ | -------------------- | | **Standard** | 200 | 2,000 | Testing, small mints | | **Partner** | 2,000 | 20,000 | Production games | | **Enterprise** | Custom | Custom | High-volume launches | Rate limits count distinct `token_id`s, not total tokens. Minting 1000 copies of the same ERC-1155 `token_id` counts as 1 request. Rate limit headers in response: ```json theme={null} { "imx_mint_requests_limit": "2000", "imx_remaining_mint_requests": "1999", "imx_mint_requests_limit_reset": "2024-02-13 07:20:00 UTC", "imx_mint_requests_retry_after": "59.98-seconds" } ``` ## Minting ERC-721 ### With Token ID (mintBatch) Use when you need specific `token_id`s (e.g., migrating existing assets): ```typescript theme={null} // Types interface MintMetadata { name: string; description?: string; image: string; external_url?: string; animation_url?: string; attributes?: Array<{ trait_type: string; value: string | number; display_type?: 'number' | 'boost_percentage' | 'boost_number' | 'date'; }>; } interface MintAsset { reference_id: string; owner_address: string; token_id?: string; metadata?: MintMetadata; } const BASE_URL = 'https://api.sandbox.immutable.com'; // or api.immutable.com for mainnet const CHAIN_NAME = 'imtbl-zkevm-testnet'; // or imtbl-zkevm-mainnet // Mint ERC-721 with token_id specified export async function mintERC721WithTokenId( contractAddress: string, assets: MintAsset[], secretApiKey: string ) { const response = await fetch( `${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/mint-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-immutable-api-key': secretApiKey, }, body: JSON.stringify({ assets }), } ); if (!response.ok) { const error = await response.json(); throw new Error(`Mint failed: ${error.message}`); } return response.json(); } // Example: Mint a single NFT with metadata export async function mintSingleNFT( contractAddress: string, recipientAddress: string, tokenId: string, metadata: MintMetadata, secretApiKey: string ) { const assets: MintAsset[] = [ { reference_id: tokenId, owner_address: recipientAddress, token_id: tokenId, metadata, }, ]; return mintERC721WithTokenId(contractAddress, assets, secretApiKey); } ``` ### Without Token ID (mintBatchByQuantity) More gas-efficient. System generates sequential `token_id`s: ```typescript theme={null} const response = await fetch( `https://api.immutable.com/v1/chains/imtbl-zkevm-mainnet/collections/${contractAddress}/nfts/mint-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-immutable-api-key': secretApiKey, }, body: JSON.stringify({ assets: [ { reference_id: 'reward-123', // Your internal ID owner_address: playerAddress, // No token_id - system assigns one metadata: { name: 'Daily Reward', image: 'https://assets.example.com/reward.png', }, }, ], }), } ); ``` Use the `reference_id` to look up the assigned `token_id` after minting completes. `mintBatchByQuantity` may return incomplete `balanceOf()` results for collections with 80,000+ tokens. Contact Immutable if you need both features. ## Minting ERC-1155 ```typescript theme={null} interface MintAssetERC1155 { reference_id: string; owner_address: string; token_id: string; amount: string; metadata?: { name: string; description?: string; image: string; attributes?: Array<{ trait_type: string; value: string | number }>; }; } const BASE_URL = 'https://api.sandbox.immutable.com'; const CHAIN_NAME = 'imtbl-zkevm-testnet'; // Mint ERC-1155 tokens export async function mintERC1155( contractAddress: string, assets: MintAssetERC1155[], secretApiKey: string ) { const response = await fetch( `${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/mint-requests`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-immutable-api-key': secretApiKey, }, body: JSON.stringify({ assets }), } ); if (!response.ok) { const error = await response.json(); throw new Error(`Mint failed: ${error.message}`); } return response.json(); } // Example: Mint starter pack with multiple item types export async function mintStarterPack( contractAddress: string, recipientAddress: string, secretApiKey: string ) { const starterPackItems: MintAssetERC1155[] = [ { reference_id: `starter-sword-${Date.now()}`, owner_address: recipientAddress, token_id: '1', // Sword amount: '1', metadata: { name: 'Iron Sword', image: 'https://example.com/sword.png', attributes: [{ trait_type: 'Attack', value: 10 }], }, }, { reference_id: `starter-potion-${Date.now()}`, owner_address: recipientAddress, token_id: '2', // Health Potion amount: '5', // Give 5 potions metadata: { name: 'Health Potion', image: 'https://example.com/potion.png', }, }, ]; return mintERC1155(contractAddress, starterPackItems, secretApiKey); } ``` ### Key Differences from ERC-721 | Aspect | ERC-1155 | | -------------------- | --------------------------------------- | | `token_id` | Always required | | `amount` | Required (number of tokens) | | Metadata | Only on **first** mint for a `token_id` | | Duplicate `token_id` | Allowed (mints additional quantity) | Passing metadata when minting to an existing `token_id` returns `409 Conflict`. Check if the token exists before including metadata. ## Tracking Mint Status ```typescript theme={null} interface MintRequestStatus { chain: { id: string; name: string }; collection_address: string; reference_id: string; status: 'pending' | 'succeeded' | 'failed'; token_id: string | null; owner_address: string; transaction_hash: string | null; error: string | null; } const BASE_URL = 'https://api.sandbox.immutable.com'; const CHAIN_NAME = 'imtbl-zkevm-testnet'; // Get mint request status by reference_id export async function getMintStatus( contractAddress: string, referenceId: string, secretApiKey: string ): Promise { const response = await fetch( `${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/mint-requests/${referenceId}`, { headers: { 'x-immutable-api-key': secretApiKey }, } ); if (!response.ok) { const error = await response.json(); throw new Error(`Status check failed: ${error.message}`); } const data = await response.json(); return data.result[0]; } // Poll for mint completion with timeout export async function waitForMint( contractAddress: string, referenceId: string, secretApiKey: string, maxAttempts = 30, intervalMs = 2000 ): Promise { for (let i = 0; i < maxAttempts; i++) { const status = await getMintStatus(contractAddress, referenceId, secretApiKey); if (status.status === 'succeeded') { console.log(`Mint succeeded! Token ID: ${status.token_id}`); return status; } if (status.status === 'failed') { throw new Error(`Mint failed: ${status.error}`); } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } throw new Error(`Mint timed out after ${maxAttempts} attempts`); } ``` ### Status Values | Status | Meaning | | ----------- | ----------------------------------------------------------- | | `pending` | Request queued, waiting for blockchain confirmation | | `succeeded` | Mint completed, `token_id` and `transaction_hash` available | | `failed` | Mint failed, see `error` field | ### Webhooks (Recommended) Configure webhooks in [Hub](https://hub.immutable.com) to receive real-time updates: ```typescript theme={null} // Webhook payload for imtbl_zkevm_mint_request_updated { event_name: 'imtbl_zkevm_mint_request_updated', data: { reference_id: 'reward-123', status: 'succeeded', token_id: '456', transaction_hash: '0x...', collection_address: '0x...', owner_address: '0x...', } } ``` ## Metadata ### Include with Mint Request (Recommended) Include metadata in the mint request for immediate indexing: ```typescript theme={null} { assets: [{ reference_id: '123', owner_address: playerAddress, token_id: '123', metadata: { name: 'Legendary Sword', description: 'A powerful weapon', image: 'https://assets.example.com/sword.png', animation_url: 'https://assets.example.com/sword.mp4', attributes: [ { trait_type: 'Rarity', value: 'Legendary' }, { trait_type: 'Damage', value: 100, display_type: 'number' }, ], }, }], } ``` ### Host at baseURI (Required) Even when including metadata in the API call, you **must** also host the metadata file at `{baseURI}/{token_id}`. Some ecosystem partners fetch directly from the source. See the metadata format specification ## Refreshing Metadata When in-game assets evolve through player actions (leveling up, crafting, performance enhancements), you need to refresh the metadata so marketplaces and other ecosystem partners reflect the updated attributes. Immutable does not listen for URI update events. You must explicitly trigger a metadata refresh for changes to propagate across the ecosystem. ### When to Refresh Metadata | Use Case | Description | | ------------------- | --------------------------------------------------- | | **Leveling Up** | Character gains experience or unlocks new abilities | | **Crafting** | Items are combined or upgraded | | **Reveals** | Delayed reveal for primary sales or mystery boxes | | **Balance Updates** | Adjusting game stats or rarity after launch | | **Content Fixes** | Correcting art, names, or descriptions | ### Refresh by Token ID Push updated metadata for specific tokens: ```typescript theme={null} interface RefreshMetadataRequest { nft_metadata: Array<{ token_id: string; name: string; description?: string; image: string; external_url?: string; animation_url?: string; attributes?: Array<{ trait_type: string; value: string | number; display_type?: 'number' | 'boost_percentage' | 'boost_number' | 'date'; }>; }>; } const BASE_URL = 'https://api.sandbox.immutable.com'; // or api.immutable.com for mainnet const CHAIN_NAME = 'imtbl-zkevm-testnet'; // or imtbl-zkevm-mainnet export async function refreshNFTMetadata( contractAddress: string, metadata: RefreshMetadataRequest, secretApiKey: string ) { const response = await fetch( `${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/refresh-metadata`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-immutable-api-key': secretApiKey, }, body: JSON.stringify(metadata), } ); if (!response.ok) { const error = await response.json(); throw new Error(`Metadata refresh failed: ${error.message}`); } // Check rate limit headers console.log('Remaining refreshes:', response.headers.get('imx_remaining_refreshes')); console.log('Limit resets at:', response.headers.get('imx_refresh_limit_reset')); return response.json(); } // Example: Update a leveled-up character await refreshNFTMetadata( contractAddress, { nft_metadata: [ { token_id: '123', name: 'Hero - Level 5', description: 'A battle-hardened warrior', image: 'https://assets.example.com/hero-level5.png', attributes: [ { trait_type: 'Level', value: 5, display_type: 'number' }, { trait_type: 'Attack', value: 25, display_type: 'number' }, { trait_type: 'Defense', value: 15, display_type: 'number' }, ], }, ], }, secretApiKey ); ``` Submit the **entire** asset metadata when refreshing—not just changed attributes. Omitted fields will be removed. ### Refresh Rate Limits | Resource | Limit | | ---------------------------------- | ---------------------------------------- | | Total NFTs updated | 677 per minute | | Token IDs per request | Up to 250 | | Total metadata size | 228 KiB per request | | Metadata stacks (by `metadata_id`) | 677 per minute, up to 10 IDs per request | Rate limit response headers: ```json theme={null} { "imx_refreshes_limit": "10000", "imx_refresh_limit_reset": "2024-02-13 15:04:05", "imx_remaining_refreshes": "9000", "retry_after": "2-seconds" } ``` Use `metadata_id` for bulk updates. Refreshing by `metadata_id` updates all NFTs that share that metadata stack, which is significantly faster than refreshing individual tokens. ### Verify Metadata Updated Metadata refreshes are asynchronous. Check the `metadata_synced_at` field via the [Indexer](/docs/products/indexer/overview) to confirm updates: ```typescript theme={null} // Query the NFT to check when metadata was last synced const nft = await fetch( `${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/${tokenId}`, { headers: { 'x-immutable-api-key': apiKey } } ).then((r) => r.json()); console.log('Metadata last synced:', nft.metadata_synced_at); // Output: "2024-02-13T15:04:05.123Z" ``` Updated metadata is typically reflected on marketplaces within \~8 seconds. ## Error Handling ### Request Errors (No Blockchain Event) | Error | Code | Cause | | ------------------------- | ---- | ----------------------------------------------------- | | `VALIDATION_ERROR` | 400 | >100 assets, duplicate `reference_id`, invalid format | | `UNAUTHORISED_REQUEST` | 401 | Invalid API key | | `AUTHENTICATION_ERROR` | 403 | Collection not linked to API key | | `CONFLICT` | 409 | `reference_id` or `token_id` already used | | `TOO_MANY_REQUESTS_ERROR` | 429 | Rate limit exceeded | ### Transaction Errors (Check via Status API) | Error | Cause | Solution | | -------------------------------------------- | ------------------------ | ----------------------------------------------- | | `AccessControl: account ... is missing role` | Minter role not granted | Grant minter role to Immutable's minting wallet | | `Exceeded maximum submissions` | Transaction retry failed | Resubmit the request | ### Idempotent Requests Safe to retry failed network requests—duplicate `reference_id` returns `409 Conflict`: ```typescript theme={null} try { await mintRequest(assets); } catch (error) { if (error.status === 409) { // Already processed - check status instead const status = await getMintStatus(referenceId); } } ``` ## Best Practices ### Batch Efficiently ```typescript theme={null} // ✅ Good: Batch multiple assets in one request (up to 100) await mint({ assets: items.slice(0, 100) }); // ❌ Bad: One request per asset for (const item of items) { await mint({ assets: [item] }); // Slow, wastes rate limit } ``` ### Use Reference IDs Wisely ```typescript theme={null} // ✅ Good: Meaningful reference_id reference_id: `reward-${eventId}-${playerId}` // ❌ Bad: Random reference_id (hard to debug) reference_id: crypto.randomUUID() ``` ### Handle Rate Limits ```typescript theme={null} // Check remaining quota before large batches if (response.imx_remaining_mint_requests < batchSize) { const resetTime = response.imx_mint_requests_limit_reset; await waitUntil(resetTime); } ``` ## Next Steps Backend minting in games NFT contracts Multi-token contracts Query minted assets Real-time mint notifications # Operator Allowlist Source: https://docs.immutable.com/docs/products/asset-contracts/operator-allowlist The Operator Allowlist restricts which addresses can transfer your tokens, ensuring royalties are enforced by only allowing transfers through compliant marketplaces like [Immutable's Orderbook](/docs/products/orderbook/overview). View the Operator Allowlist source code ## Why It's Required The Operator Allowlist protects game studios from: * **Vampire attacks**: Unauthorized marketplaces bypassing royalties * **Protocol fee evasion**: Trading outside Immutable's ecosystem * **Revenue loss**: Transactions that don't pay creator royalties **Mandatory Compliance**: All ERC-721 and ERC-1155 collections on Immutable Chain must implement the Operator Allowlist. ### Non-Compliance Consequences * Forfeit any token grants received * Passport users see warnings that your collection may be counterfeit * Exclusion from ecosystem marketplaces (TokenTrove, GameStop NFT, etc.) ## How It Works The allowlist is a registry of approved operator addresses: | Address Type | Example | | -------------------------- | ------------------------------------- | | **Contract Address** | Seaport settlement contract | | **Bytecode Hash** | Smart contract wallet implementations | | **Implementation Address** | Proxy contract targets | ### Enforcement Flows **Approvals** (`approve`, `setApprovalForAll`): | Scenario | Allowed | | ------------------------------ | ------- | | Target is an EOA (user wallet) | ✅ | | Target has approved bytecode | ✅ | | Target has approved address | ✅ | | Unapproved smart contract | ❌ | **Transfers** (`transferFrom`, `safeTransferFrom`): | Scenario | Allowed | | ------------------------------ | ------- | | Caller is an EOA | ✅ | | Caller is approved marketplace | ✅ | | Unapproved contract | ❌ | ## Implementation ### Using Preset Contracts All [Immutable preset contracts](/docs/products/asset-contracts/overview) and contracts deployed via [Hub](https://hub.immutable.com) include Operator Allowlist protection by default. ### Custom Contracts For custom contracts, inherit from `OperatorAllowlistEnforced.sol`: ```solidity theme={null} import "@imtbl/contracts/contracts/allowlist/OperatorAllowlistEnforced.sol"; contract MyNFT is ERC721, OperatorAllowlistEnforced { constructor(address allowlist) { _setOperatorAllowlistRegistry(allowlist); } function _beforeTokenTransfer( address from, address to, uint256 tokenId ) internal override { // Check operator allowlist for transfers if (from != address(0) && to != address(0)) { require( _isAllowlisted(msg.sender), "Operator not allowlisted" ); } super._beforeTokenTransfer(from, to, tokenId); } } ``` ## Operator Allowlist Addresses Get the current address from the Immutable API: | Network | Chain ID | API Endpoint | | ----------- | -------------- | ---------------------------------------------------------------------------- | | **Testnet** | `eip155:13473` | `https://api.sandbox.immutable.com/v1/chains` → `operator_allowlist_address` | | **Mainnet** | `eip155:13371` | `https://api.immutable.com/v1/chains` → `operator_allowlist_address` | ```bash theme={null} # Get testnet address curl https://api.sandbox.immutable.com/v1/chains | jq '.result[] | select(.name == "imtbl-zkevm-testnet") | .operator_allowlist_address' # Get mainnet address curl https://api.immutable.com/v1/chains | jq '.result[] | select(.name == "imtbl-zkevm-mainnet") | .operator_allowlist_address' ``` Immutable manages the allowlist with pre-approved addresses including Seaport and smart contract wallet deployments. Use this instead of deploying your own. ## Request Allowlist Addition To add your contract to the Operator Allowlist: Verify your contract on [Immutable Explorer](https://explorer.immutable.com/contract-verification). See the [verification guide](/docs/products/asset-contracts/erc721#deploy-via-code). In [Hub](https://hub.immutable.com), go to **Contracts** and click **Link Contract**. On your contract's detail page, click **Add to OAL** and follow the instructions. * **Testnet**: Typically seconds (automated) * **Mainnet**: Up to one week (manual review) ## Pre-Approved Operators The following are already on the Operator Allowlist: * [Immutable Orderbook](/docs/products/orderbook/overview) * Immutable smart contract wallets * Major ecosystem marketplaces ## Interface The `IOperatorAllowlist` interface provides: ```solidity theme={null} interface IOperatorAllowlist { /// @notice Check if an address is allowlisted /// @param target The address to check /// @return bool True if allowlisted function isAllowlisted(address target) external view returns (bool); } ``` Configure royalty payments Deploy a compliant contract # Asset Contracts Source: https://docs.immutable.com/docs/products/asset-contracts/overview Pre-built smart contracts for NFTs and in-game currencies Unique NFTs for characters, weapons, land, and collectibles Multi-tokens for stackable items and editions Fungible tokens for in-game currencies ## Why Use Immutable Contracts? Audited contracts with proven security track record. Trusted by games managing millions in player assets. Built-in royalties, operator allowlists, and batch operations designed specifically for game economies. Deploy from Hub with automatic Minting API setup—no Solidity required. Import `@imtbl/contracts` to extend base contracts with custom game logic. ## Deployment Deploy contracts with zero code via [Hub](https://hub.immutable.com) or programmatically: ```bash theme={null} npm install @imtbl/contracts ``` See individual contract pages for implementation examples. ## Operator Allowlist Controls which contracts can transfer your NFTs. For details, see [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist). [Immutable's Orderbook](/docs/products/orderbook/overview) and major marketplaces are pre-approved. ## Royalties All contracts support EIP-2981 royalties: * Set percentage at deployment (e.g., 5% = 500 basis points) * Enforced on [Immutable's Orderbook](/docs/products/orderbook/overview) * Automatic payment on secondary sales ## Verification Get your collections verified to build trust with players and ecosystem partners. ### Verification Badge Verified collections display a green checkmark across Immutable's ecosystem, indicating legitimacy to the community. Link contracts to [Hub](https://hub.immutable.com) and request verification. Verified collections display a green checkmark on marketplaces and ecosystem apps. ### Inactive Collections Mark test or abandoned collections as **Inactive** in Hub to: * Delist from marketplace search results * Signal to players not to transact with these assets * Keep your game's collection list clean ## Trading Rewards Register your collections with Immutable's [Trading Rewards](https://www.digitalworldsnfts.com/trading-rewards) program to incentivize secondary market activity. Collections must have royalties set to at least **0.5%** to be eligible. Contact your Immutable account manager to register. View registered collections at [imx.community/rewards](https://imx.community/rewards?t=collections). ## Next Steps Deploy contracts via Hub Mint NFTs for your collections Configure royalty payments Enforce trading restrictions # Royalties Source: https://docs.immutable.com/docs/products/asset-contracts/royalties Immutable's ecosystem enforces royalty fees to content creators when assets are bought and sold. Royalty fees are set during minting and configured in the smart contract. View royalty implementation source code ## How Royalties Work When an NFT or SFT is sold on the secondary market, a royalty fee automatically directs a portion of the transaction value to the original creator. | Component | Description | | ---------------------- | --------------------------------------------------------------------------- | | **Royalty Percentage** | Set at contract deployment (e.g., 5% = 500 basis points) | | **Recipient** | Address that receives royalties (can be a Fee Splitter) | | **Standard** | EIP-2981 for on-chain royalty info | | **Enforcement** | Via [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist) | Royalty fees are optional—it's up to the collection owner. If set, Immutable's ecosystem enforces payment. ## Setting Royalties ### Via Hub 1. Go to [Hub](https://hub.immutable.com) → **Contracts** → **Deploy** 2. Select your contract type (ERC-721 or ERC-1155) 3. Configure: * **Royalty Recipient**: Address to receive royalties * **Royalty Percentage**: Percentage of sale price (e.g., 5%) 4. Deploy contract ### Via Code ```solidity theme={null} import "@imtbl/contracts/contracts/token/erc721/preset/ImmutableERC721.sol"; contract MyNFT is ImmutableERC721 { constructor( address owner, string memory name, string memory symbol, string memory baseURI, string memory contractURI, address operatorAllowlist, address royaltyReceiver, // Royalty recipient uint96 royaltyFeeNumerator // 500 = 5% ) ImmutableERC721( owner, name, symbol, baseURI, contractURI, operatorAllowlist, royaltyReceiver, royaltyFeeNumerator ) {} } ``` ## Fee Splitter For distributing royalties to multiple recipients, use the Fee Splitter contract. View the Fee Splitter source code ### Key Features * **Multi-Recipient Support**: Allocate fees to multiple recipients * **On-Chain Transparency**: Wallet addresses and shares stored on-chain * **Gas Efficiency**: More efficient than real-time splitting ### How It Works 1. Set the Fee Splitter contract as your royalty recipient 2. Configure recipients and their percentage shares 3. Fees accumulate in the Fee Splitter contract 4. Call `releaseAll()` to distribute accumulated fees ```solidity theme={null} // Releasing fees to all recipients feeSplitter.releaseAll([USDC_ADDRESS, WETH_ADDRESS]); // Release only native token (IMX) feeSplitter.releaseAll(); ``` ### ERC-20 Allowlist The Fee Splitter includes an allowlist for accepted tokens: ```solidity theme={null} // Add token to allowlist feeSplitter.addToAllowlist(ERC20_ADDRESS); // Remove token from allowlist feeSplitter.removeFromAllowlist(ERC20_ADDRESS); ``` Unlisted tokens sent to the Fee Splitter won't be distributed until added to the allowlist. ### Updating Allocation Admin users can update fee allocation. When updated: * All unreleased fees are redistributed according to the new configuration * Existing minted assets automatically use the new configuration Call `releaseAll()` before changing allocation to distribute fees according to the original configuration. ## Multiple Fee Splitters Different scenarios require separate Fee Splitter contracts: | Scenario | Solution | | --------------------- | --------------------------------------------- | | Different recipients | One contract per unique recipient combination | | Different percentages | One contract per unique split ratio | | Multiple games | One contract per game/collection | ## Royalty Enforcement Royalties are enforced through the [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist). Only allowlisted marketplaces (like [Immutable's Orderbook](/docs/products/orderbook/overview)) can transfer tokens, ensuring royalties are always paid. Learn how royalty enforcement works Deploy a contract with royalties ## Next Steps Learn about royalty enforcement Deploy via Hub with royalties Explore contract options Enable secondary trading # Conversion Goals Source: https://docs.immutable.com/docs/products/attribution/conversion-goals Define which player actions count as a conversion so only the right events are forwarded to your connected ad networks Conversion Goals are currently in **alpha**. Supported events and behavior may change between releases. **Who is this for?** Marketers and game developers who want control over which in-game actions trigger postbacks to ad networks, rather than forwarding every supported event. Conversion Goals let you define which player actions count as a conversion for your game. Only the actions you configure are forwarded to your connected ad networks. ## What Is a Goal? A Goal is a rule that defines which player actions count as a conversion. For example: * When a player completes a purchase, that counts as *First Purchase* * When a player adds your game to their wishlist, that counts as *Wishlist Add* * When a player submits their email, that counts as *Email Captured* A Goal is made up of: | Part | What it does | | ---------------------------- | --------------------------------------------------------- | | **Name** | A label you choose so you can find the Goal later | | **Description** *(optional)* | A note describing what the Goal tracks | | **Event** | The player action that can trigger the Goal | | **Trigger** | Whether the Goal counts once per player or every time | | **Filter** *(optional)* | Narrows the Goal to events with a specific property value | Goals are scoped per game. A Goal you create for one game does not appear in another. ## How to Create a Goal Open [Audience Hub](https://hub.immutable.com), navigate to your game, then go to **Settings** then **Goals**. If your game has no Goals yet, the Goals page shows quick-start options for common actions such as first purchase, wishlist add, email capture, and first launch. These shortcuts pre-fill the event and trigger. They only appear while the Goals list is empty. To configure a Goal yourself, select **Create goal**. The wizard has up to three steps: Select **Built-in** to use one of Immutable's pre-built events, or select **Custom event** to use an event defined by your game. For a custom event, you can choose an event seen in your game's data, reuse an event from another Goal, or enter a new event name. Custom event names are case-sensitive and must exactly match the `eventName` your game sends. A Goal for `level_complete` does not match an event named `Level_Complete`. Optionally narrow when the Goal fires. Built-in events provide their supported properties. For a custom event, enter the property name, select its type, choose an operator, and enter the value. This step is skipped when the selected built-in event has no filterable properties. Enter a name and optional description, confirm the event, trigger, and filters, then select **Create goal**. ## Triggers | Trigger | What it does | Good for | | ------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------- | | **Once per player** | Only the first time a player does the action counts. Later occurrences are ignored. | Wishlist add or email capture, which happen once per player | | **Repeatable** | Every occurrence counts. | Purchases, where every purchase is a conversion | When you create a Goal you choose **Once per player**, but a saved Goal displays this trigger as **First-time only**. The two labels mean the same thing. ## Filters A filter narrows a Goal so it only counts events that match a specific property value. For example, count a purchase only when `value` is greater than 10. Filters are optional and can be combined with either trigger. The available operators depend on the property type: | Property type | Operators | | -------------------------------------------------- | ---------------------------------- | | Numeric (`value` or custom **Number**) | `=`, `≠`, `>`, `≥`, `<`, `≤`, `in` | | String (`currency`, `game_id`, or custom **Text**) | `=`, `≠`, `in` | | Boolean (custom **Boolean**) | `=`, `≠` | You can add up to five filters to a Goal. Every filter must match for the Goal to fire. ## Built-in Events Conversion Goals provides five built-in events. You can also create a Goal from a custom event your game sends. Filterable properties for built-in events depend on the event: | Event | What it means | Filterable properties | | ------------------- | ---------------------------------------------- | --------------------- | | **Purchase** | A player completes a purchase | `currency`, `value` | | **Wishlist add** | A player adds your game to their wishlist | `game_id` | | **Wishlist remove** | A player removes your game from their wishlist | `game_id` | | **Email acquired** | A player submits their email address | None | | **Game launch** | A player launches your game | None | ## How Goals Connect to Postbacks [Conversion Postbacks](/docs/products/attribution/postbacks/overview) are server-to-server callbacks Immutable sends to your connected ad networks when a conversion happens. A Goal defines what counts as a conversion; a postback is what Immutable sends when one occurs. A postback only fires when an attributed event matches an active Goal. The path from a player action to an ad network: Your game sends an event via the Unity SDK, Web SDK, or REST API. Immutable's attribution engine checks whether the event is linked to a paid ad click captured by the Web SDK. The event is checked against every active Goal for the game. If it matches a Goal's event, trigger, and filters, the conversion is recorded. Immutable sends a postback for that conversion to every connected ad network. If no Goal matches, no postback is sent. In Conversion Goals, a matching conversion fires to all connected ad networks. Per-Goal network selection is not available yet. To manage which networks receive postbacks, go to **Settings** then **Integrations**. ## Managing Goals From **Settings** then **Goals** you can: * **View** all active Goals and their configuration * **Edit** a Goal's name, description, event, trigger, or filter. Changing the event clears any filter clauses, because they may not apply to the new event. * **Archive** a Goal to stop it matching new events. Archiving is permanent and cannot be reversed. Archived Goals are kept for reference and do not count toward the active limit. * **Filter** the list by active or archived status Each game can have up to **50 active Goals**. Archived Goals do not count toward this limit. If you reach the cap, archive an existing Goal before creating a new one. ## Current Limitations * **Exact custom event names.** Custom event matching is case-sensitive. Immutable does not correct misspelled or differently cased event names. * **No retention-window triggers.** Conditions like "active on day 7" are not available yet. * **All Goals fire to all connected networks.** Per-Goal network selection is not available yet. ## FAQ Yes. You can edit a Goal's name, description, event, trigger, and filter at any time. If you change the event, any existing filter clauses are cleared because they may not apply to the new event. Yes. Each matching Goal records its own conversion and sends its own postback. For example, two active Goals on the `purchase` event with different filters will each fire independently if a purchase satisfies both. Archiving stops the Goal matching new events straight away. It cannot be reversed. Conversion records from before archiving are preserved, and archived Goals do not count toward the 50-active-Goal limit. To see archived Goals, switch the filter on the Goals page to **Archived**. ## Next Steps Configure which ad networks receive postbacks when a Goal is matched How Immutable attributes player actions back to ad campaigns Send in-game conversion events from Unity builds Send events from web games and marketing sites Send events from your backend or game server # Funnel Page Source: https://docs.immutable.com/docs/products/attribution/funnel-page High-conversion pre-launch landing page that feeds Audience CDP and Attribution Bearly Surviving Funnel Page A Funnel Page is a dedicated landing experience on [Immutable Play](https://play.immutable.com). For **Steam games**, it sits beside the wishlist button: Steam still owns the store wishlist; you own a contactable audience you can nurture with [Engage](/docs/products/audience/engage) until (and after) launch. Signups and links write into [Audience CDP](/docs/products/audience/overview) so Engage, [Analytics](/docs/products/audience/analytics/overview), and [Attribution](/docs/products/attribution/overview) share the same profiles. See a live Funnel Page in action ## How it fits | Product | Role of the Funnel Page | | ---------------- | ----------------------------------------------------------------------------- | | **Attribution** | Destination for paid and organic traffic you want to measure | | **Audience CDP** | Automatic [ingestion](/docs/products/audience/ingestion) of signups and links | | **Engage** | Lifecycle messaging against those signups | Funnel Page traffic needs no separate marketing-site pixel. You can also serve the page from [your own domain](/docs/products/attribution/funnel-page-custom-domain), or [embed just the sign-up drawer](/docs/products/attribution/funnel-page-embed) on your existing site. To measure your own site or game client, add SDKs via Attribution / Ingestion. ## Why use one (especially with Steam) | Steam wishlist / store page alone | Funnel Page + wishlist | | --------------------------------- | -------------------------------------- | | Anonymous intent on Steam | Known players in your CDP | | Only Valve can email them | You message via Engage | | Static store content | Content you control, updated anytime | | UA optimizes for wishlists only | Attribution can track signup → install | Keep the Steam wishlist CTA. Add the Funnel Page so paid and organic traffic also create an owned profile. ## How it works Send paid and organic traffic to your Funnel Page instead of only Steam or your site Players create accounts and link their social channels Linked accounts and activity become Audience profiles Multi-channel engagement expands reach Convert engaged players into Day 1 actives ## Features Connect Discord, X, Telegram, and more Drop the sign-up drawer directly onto your own marketing site Signups and links ingest into Audience CDP with no extra pixel ## Page components * **Hero** — Branding, trailer, countdown, platform availability * **Art gallery** — Screenshots and concept art * **Community** — Fan count and community activity ## Acquisition multiplier ``` Traditional: 1000 clicks → 200 wishlists → 40 Day 1 players Funnel Page: 1000 clicks → 200 signups → retention → more Day 1 players ``` Gains come from higher click-to-signup conversion, re-engagement, and multi-channel follow-up via Engage. ## Setup Configure everything in [Hub](https://hub.immutable.com) — branding, content, and social channels — with no engineering work and no release cycle to wait for. If you run Google Ads, add a **Google Tag ID** on the Funnel Page so sign-ups (Email Captured) are reported by `gtag`. Do not map that goal to [Google Ads postbacks](/docs/products/attribution/postbacks/google-ads). Postbacks should not be used for website events like Funnel Page sign-up. The template is deliberately opinionated. It is tuned for one job, turning a visitor into a contactable signup, so you control the content and the branding rather than the layout. If that shape doesn't fit the campaign you have in mind, [use your own website](/docs/products/attribution/funnel-page-embed) instead and embed the sign-up drawer into a page you design yourself. Editing is self-serve end to end: * **Save Draft** — work on changes without touching the live page, and use **Live preview** to see them as players will * **Publish** — push the draft live when you're ready, or unpublish the page again * **Schedule publish** — pick a date and time instead, so a trailer drop or store-page beat goes out on cue rather than someone staying up for it. A pending schedule can be cancelled any time before it fires * **Version history** — a record of every published version, so you can see exactly what changed and when Set up your page in Hub ### Hosting options Serve the page from your own domain instead of `play.immutable.com` Put the sign-up drawer on your existing marketing site ## Pre-launch metrics | Metric | Description | | ------------------- | --------------------------- | | **Signups** | Players on your Funnel Page | | **Conversion rate** | Visitors → signups | | **Engagement** | Return visits | Deeper cohort and quality views live in [Audience Analytics](/docs/products/audience/analytics/overview). Source performance lives in [Attribution](/docs/products/attribution/overview). ## Next steps Measure campaigns that drive Funnel Page traffic How Funnel Page data enters the CDP Nurture Funnel Page signups # Host on your domain Source: https://docs.immutable.com/docs/products/attribution/funnel-page-custom-domain Serve your Funnel Page from your own domain instead of play.immutable.com By default your [Funnel Page](/docs/products/attribution/funnel-page) is served at `play.immutable.com/games/{slug}`. Configure a custom domain in Hub to serve it from your own domain instead — useful when you want a paid campaign or a Steam store page's "learn more" link to land somewhere entirely first-party. Immutable provisions hosting and TLS; you keep editing the page in Hub exactly as before. A custom domain replaces the whole path, not just the hostname in front of it. Once active, the page serves from `https://{yourdomain}/` directly — there is no `/games/{slug}` segment — and short links you create for this game point straight at the custom domain too. ## Configure in Hub Link a game to your project first. Go to your project in [Hub](https://hub.immutable.com) → **Settings** → **Custom Domains** → **Game page domain**. Enter the domain you want your Funnel Page to serve from (for example `play.yourgame.com`). Both an apex domain (`yourgame.com`) and a subdomain (`play.yourgame.com`) are supported. Click **Setup** to reveal the records to add at your DNS provider (see below), then add them. With the records in place, click **I've done this**. Hosting, routing, and a TLS certificate are provisioned automatically — you never upload or manage a certificate yourself. ## DNS records Records depend on whether you point an apex domain or a subdomain at your Funnel Page. **Apex domain** (for example `yourgame.com`): | Type | Name | Value | | ----- | --------------- | ----------------------------------------------------------------- | | A | `@` | Three static IP addresses (shown in Hub once you click **Setup**) | | CNAME | `www` | A per-game hostname (shown in Hub) | | CNAME | `_cf-challenge` | A per-game challenge hostname (shown in Hub) | **Subdomain** (for example `play.yourgame.com`): | Type | Name | Value | | ----- | ------------------- | ---------------------------------- | | CNAME | your full subdomain | A per-game hostname (shown in Hub) | A subdomain is the simpler path — one CNAME record, and no certificate-challenge record to manage. Once you've added the domain in Hub, it may also show a certificate validation CNAME — add that too. TLS issuance can take up to 30 minutes after that record appears. Preview links for unpublished games always serve from `play.immutable.com`. A custom domain only ever serves the published Funnel Page, never `/preview`. ## Next steps Define which player actions count as a conversion Nurture the signups you capture # Use your own website Source: https://docs.immutable.com/docs/products/attribution/funnel-page-embed Put the Funnel Page sign-up drawer on your existing marketing site with one script tag Instead of sending traffic to a Play-hosted URL, you can embed the [Funnel Page](/docs/products/attribution/funnel-page) sign-up drawer directly into your own marketing site. A loader script mounts an overlay that opens on demand and carries the whole sign-up flow — email capture and one-time passcode (OTP) verification — without sending the visitor anywhere else. ## Prerequisite: a first-party subdomain You delegate a subdomain to Immutable by CNAME (for example `landing.studio.com`), and Immutable serves the drawer from it. You then add a ` ``` Replace `landing.studio.com` with your own delegated subdomain. * Place the tag right before `` so `` already exists and the drawer can mount immediately. In `` it also works — it waits for `DOMContentLoaded` — but mounts slightly later. * Use a real ` ``` Each queued entry is `[methodName, ...args]`, using the same methods as above. After the loader runs, `window.imtbl.push([...])` keeps working as an alias for calling the method directly. You don't need to queue anything just to load the drawer — the loader mounts its iframe as soon as it runs, so attribution is captured even for a visitor who never opens it. ## Events Register handlers with `imtbl.embed.on(event, callback)`. | Event | Fires when | Payload | | ------------------ | ------------------------------------------------------------------------------------- | ---------------------------------- | | `ready` | The drawer is connected and the API is usable. Fires once per page load; sticky. | none | | `open` | The drawer becomes visible and interactive. | none | | `close` | The drawer becomes hidden and click-through again. | none | | `signup:started` | A visitor has submitted an email inside the drawer. | none | | `signup:completed` | The visitor has verified their OTP. | none | | `error` | The connection to the drawer failed and can't recover for the rest of this page load. | `{ code }` — see [Errors](#errors) | `signup:completed` carries no email, because any third-party script on your page could observe the event. The verified address reaches you through an authenticated export instead. ## Errors | Code | Meaning | Typical cause | | ------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | `handshake-timeout` | The drawer never answered the initial handshake. | CSP missing your delegated subdomain, or framing from outside your apex. | | `version-mismatch` | The drawer answered with a wire-protocol version this loader doesn't understand. | Rare and transient — a browser holding a cached loader from just before a protocol change. Resolves on the next fetch. | Either error is terminal for the rest of the page load: the drawer never becomes usable, and the loader stops intercepting clicks on `data-imtbl-cta` elements. An `` reverts to a plain link and navigates to its `href`; a ` ); } ``` ### Popup Login ```tsx theme={null} const { loginWithPopup } = useLogin(); await loginWithPopup(loginConfig); ``` ### Redirect Login ```tsx theme={null} const { loginWithRedirect } = useLogin(); // Navigates away from the page for OAuth authentication await loginWithRedirect(loginConfig); ``` ### Direct Login Options Skip the login method selection screen and go directly to a specific provider: ```tsx theme={null} import { MarketingConsentStatus } from '@imtbl/auth-next-client'; const { loginWithPopup } = useLogin(); // Direct to Google login await loginWithPopup(config, { directLoginOptions: { directLoginMethod: 'google', marketingConsentStatus: MarketingConsentStatus.OptedIn, }, }); // Direct to email login with marketing consent await loginWithPopup(config, { directLoginOptions: { directLoginMethod: 'email', email: 'user@example.com', marketingConsentStatus: MarketingConsentStatus.OptedIn, }, }); ``` | Option | Description | | ------------------------ | ----------------------------------------------------------- | | `directLoginMethod` | The authentication provider (`email`, `google`, or `apple`) | | `email` | Required when `directLoginMethod` is `email` | | `marketingConsentStatus` | Marketing consent (`OptedIn` or `Unsubscribed`) | ### Headless Login For a more streamlined user experience, the standard Passport login prompt can be bypassed by providing the `directLoginOptions` parameter to the `login` method. This allows you to render a customised authentication prompt within your own application. Once an authentication option (email, Google, Apple, or Facebook) is passed to the `login` method, a popup will be opened so that the user can authenticate securely. ```typescript theme={null} // Headless login with email const user = await auth.login({ directLoginOptions: { directLoginMethod: 'email', email: 'user@example.com', marketingConsentStatus: 'opted_in' } }); // Headless login with specific provider const user = await auth.login({ directLoginOptions: { directLoginMethod: 'google', // or 'apple', 'facebook' marketingConsentStatus: 'opted_in' } }); ``` | Option | Description | | ------------------------ | -------------------------------------------------------------------------------- | | `directLoginMethod` | The authentication provider (`email`, `google`, `apple`, or `facebook`) | | `email` | Required when `directLoginMethod` is `email`, specifies the user's email address | | `marketingConsentStatus` | Marketing consent setting (`opted_in` or `unsubscribed`) | ### Login with EthersJS Integrates Passport authentication with EthersJS for wallet connection and interaction. ```typescript theme={null} const provider = await connectWallet({ auth }); const web3Provider = new BrowserProvider(passportProvider); const accounts = await web3Provider.send('eth_requestAccounts', []); ``` This implementation uses EthersJS's BrowserProvider to interact with the Passport provider. It requests user accounts and manages the connection state, displaying the connected account address when successful. ### Identity-only Login The `login` method can be used to log in a user and retrieve their profile information without connecting to Immutable zkEVM. ```typescript theme={null} const profile: User | null = await auth.login(); ``` This will prompt the user to select an authentication option from within an iFrame, and open a popup to securely complete the authentication process. ### Embedded Login The PassportUI prefab provides a seamless, embedded authentication experience that keeps users within your application. This approach offers better user experience and higher conversion rates. #### Setting up the PassportUI Prefab | Platform | WebView Package | | ----------------- | -------------------------------------------------------------- | | Windows | Volt Unity Web Browser (UWB) - no additional packages required | | iOS/Android/macOS | Vuplex WebView (paid third-party product) | **1. Add the PassportUI Prefab to Your Scene** Choose the appropriate prefab for your target platform: * `PassportLogin_Windows.prefab` - For Windows builds using UWB * `PassportLogin_Vuplex.prefab` - For iOS, Android, and macOS builds using Vuplex **2. Initialize PassportUI** ```csharp theme={null} public class MyAuthScript : MonoBehaviour { public PassportUI passportUI; async void Start() { // Initialize with existing Passport instance await passportUI.InitializeWithPassport(Passport.Instance); // Or initialize PassportUI with automatic Passport creation // (uses the clientId/environment/URIs set in the Inspector) await passportUI.InitializeWithPassport(); } } ``` **3. Handle Authentication Events** ```csharp theme={null} void Start() { // Subscribe to authentication events Passport.Instance.OnAuthEvent += OnAuthEvent; } private void OnAuthEvent(PassportAuthEvent authEvent) { switch (authEvent) { case PassportAuthEvent.LoginPKCESuccess: Debug.Log("User logged in successfully"); break; case PassportAuthEvent.LogoutPKCESuccess: Debug.Log("User logged out"); break; case PassportAuthEvent.ReloginSuccess: Debug.Log("User re-logged in with cached credentials"); break; } } ``` For iOS, Android, and macOS, the embedded WebView requires [Vuplex WebView](https://developer.vuplex.com/webview/overview), a paid third-party package. Alternatively, use Standard Login which doesn't require any WebView. ### Headless Login For a more streamlined user experience, bypass the standard Passport login prompt by providing the `DirectLoginOptions` parameter: ```csharp theme={null} // Customized login with email await passport.Login(directLoginOptions: new DirectLoginOptions( DirectLoginMethod.Email, "user@example.com", MarketingConsentStatus.OptedIn )); // Customized login with specific provider await passport.Login(directLoginOptions: new DirectLoginOptions( DirectLoginMethod.Google // or Apple, Facebook )); ``` | Option | Description | | ------------------------ | -------------------------------------------------------------------------------- | | `DirectLoginMethod` | The authentication provider (`Email`, `Google`, `Apple`, or `Facebook`) | | `email` | Required when `DirectLoginMethod` is `Email`, specifies the user's email address | | `marketingConsentStatus` | Marketing consent setting (`OptedIn` or `Unsubscribed`) | ### Standard Login Use the basic `Login()` method to show the standard Passport login prompt: ```csharp theme={null} await passport.Login(); ``` This will open an external browser window on desktop or an in-app browser on mobile devices, prompting the user to select an authentication option and complete the authentication process securely. ### Stored Credentials Once the gamer is connected to Passport, the SDK will store credentials (access, ID, and refresh tokens). Use `Login(useCachedSession: true)` to re-login using saved credentials. ```csharp theme={null} bool hasCredsSaved = await passport.HasCredentialsSaved(); if (hasCredsSaved) { await passport.Login(useCachedSession: true); // Successfully re-logged into Passport } ``` ### Standard Login After Passport initialization completes, call `PerformLogin()` to authenticate the user. ```cpp theme={null} // Add to your subsystem: public: void PerformLogin() { auto Passport = GetPassport(); if (!Passport) { return; } Passport->Connect ( true, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnLoginComplete) ); } private: void OnLoginComplete(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Login Failed: %s"), *Result.Error); return; } UE_LOG(LogTemp, Log, TEXT("Login Successful")); } void OnPassportInitialized(FImmutablePassportResult Result) { if (!Result.Success) return; UE_LOG(LogTemp, Log, TEXT("Passport Initialized Successfully")); PerformLogin(); } ``` ### Headless Login For a more streamlined user experience, bypass the standard Passport login prompt by providing the `DirectLoginOptions` parameter. Once an authentication option (email, Google, Apple, or Facebook) is passed, a popup will be opened so that the user can authenticate securely. ```cpp theme={null} // Add to your subsystem: public: void PerformHeadlessLogin() { auto Passport = GetPassport(); if (!Passport) { return; } // Customised login with email Passport->Connect ( true, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnLoginComplete), {.DirectLoginMethod = EImmutableDirectLoginMethod::Email} ); } private: void OnLoginComplete(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Login Failed: %s"), *Result.Error); return; } UE_LOG(LogTemp, Log, TEXT("Login Successful")); } void OnPassportInitialized(FImmutablePassportResult Result) { if (!Result.Success) return; UE_LOG(LogTemp, Log, TEXT("Passport Initialized Successfully")); PerformHeadlessLogin(); } ``` | Option | Description | | ------------------------ | -------------------------------------------------------------------------------- | | `DirectLoginMethod` | The authentication provider (`Email`, `Google`, `Apple`, or `Facebook`) | | `Email` | Required when `DirectLoginMethod` is `Email`, specifies the user's email address | | `MarketingConsentStatus` | Marketing consent setting (`Opted_In` or `Unsubscribed`) | ### Standard Login Use the basic `Login()` method without any parameters to show the standard Passport login prompt: ```cpp theme={null} UImtblConnectionAsyncActions::Login(/* WorldContextObject */ this); ``` This will open an external browser window on desktop or an in-app browser on mobile devices, prompting the user to select an authentication option and complete the authentication process securely. ### Stored Credentials Once the gamer is connected to Passport, the SDK will store credentials (access, ID, and refresh tokens). You can use the `HasStoredCredentials` async action to check if the gamer has stored credentials before deciding whether to show login options in your UI. The `HasStoredCredentials` async action is currently Blueprint-compatible only. For C++, use the Passport instance methods directly. ## Handle the Callback On your redirect URI page, process the authentication callback: ```tsx theme={null} // app/callback/page.tsx 'use client'; import { CallbackPage, type ImmutableAuthConfig } from '@imtbl/auth-next-client'; const config: ImmutableAuthConfig = { clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!, redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`, audience: 'platform_api', scope: 'openid profile email offline_access transact', }; export default function Callback() { return ( Completing authentication...

} /> ); } ``` The `CallbackPage` component handles both redirect and popup flows automatically. For popup logins, it communicates tokens back to the parent window and closes itself.
```tsx theme={null} // pages/callback.tsx or app/callback/page.tsx import { useEffect } from 'react'; import { useRouter } from 'next/router'; export default function Callback() { const router = useRouter(); useEffect(() => { async function handleCallback() { try { await auth.loginCallback(); router.push('/dashboard'); } catch (error) { console.error('Callback error:', error); router.push('/login?error=callback_failed'); } } handleCallback(); }, []); return (

Completing login...

); } ```
```typescript theme={null} // callback.ts async function handleCallback() { try { await auth.loginCallback(); window.location.href = '/dashboard'; } catch (error) { console.error('Callback error:', error); window.location.href = '/login?error=callback_failed'; } } handleCallback(); ```
## Get User Information ### User Profile ```tsx theme={null} 'use client'; import { useImmutableSession } from '@imtbl/auth-next-client'; function UserProfile() { const { session, isAuthenticated } = useImmutableSession(); if (!isAuthenticated) return null; return (

User ID: {session?.user?.sub}

Email: {session?.user?.email}

Wallet: {session?.zkEvm?.ethAddress}

); } ```
```typescript theme={null} // Get user info from the ID token const userInfo = await auth.getUserInfo(); console.log({ userId: userInfo.sub, // Unique Passport user ID email: userInfo.email, // If email scope granted emailVerified: userInfo.email_verified, }); ``` ```csharp theme={null} var userInfo = await passport.GetUserInfo(); Debug.Log($"User ID: {userInfo.Sub}"); Debug.Log($"Email: {userInfo.Email}"); ``` ```cpp theme={null} // Add to your subsystem: private: void OnLoginComplete(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Login Failed: %s"), *Result.Error); return; } UE_LOG(LogTemp, Log, TEXT("Login Successful")); auto Passport = GetPassport(); // Get user email Passport->GetEmail(UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult EmailResult) { if (EmailResult.Success) { FString Email = UImmutablePassport::GetResponseResultAsString(EmailResult.Response); UE_LOG(LogTemp, Log, TEXT("User Email: %s"), *Email); } })); // Get user wallet address Passport->GetAddress(UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult AddressResult) { if (AddressResult.Success) { FString Address = UImmutablePassport::GetResponseResultAsString(AddressResult.Response); UE_LOG(LogTemp, Log, TEXT("Wallet Address: %s"), *Address); } })); } ``` **Note:** The Unreal SDK provides separate methods (`GetEmail`, `GetAddress`) rather than a unified `GetUserInfo()` method. See [Get ID Token](#get-id-token) and [Get Access Token](#get-access-token) sections for retrieving tokens.
## Session Management ### Check If Logged In Always use `isAuthenticated` from `useImmutableSession` to determine if a user is logged in. ```tsx theme={null} 'use client'; import { useImmutableSession } from '@imtbl/auth-next-client'; function ProtectedContent() { const { isAuthenticated, isLoading } = useImmutableSession(); if (isLoading) return
Loading...
; if (!isAuthenticated) return
Please log in
; return
Protected content
; } ``` Do not use `!!session` or `status === 'authenticated'` to check auth state. A session object can exist with expired or invalid tokens, and `status` does not account for token-level errors like `RefreshTokenError`. `isAuthenticated` validates all of the following: 1. NextAuth reports `'authenticated'` status 2. The session object exists 3. A valid access token is present in the session 4. No session-level error exists (such as `RefreshTokenError`) It also handles transient states gracefully -- during session refetches (window focus) or manual refreshes (after wallet registration via `getUser(true)`), `isAuthenticated` remains `true` if the user was previously authenticated, preventing UI flicker. ```tsx theme={null} // Correct const { isAuthenticated } = useImmutableSession(); if (!isAuthenticated) return
Please log in
; // Incorrect -- session can exist with expired/invalid tokens const { session } = useImmutableSession(); if (!session) return
Please log in
; // Incorrect -- status doesn't account for token errors const { status } = useImmutableSession(); if (status !== "authenticated") return
Please log in
; ```
```typescript theme={null} async function isLoggedIn(): Promise { return auth.isLoggedIn(); } ``` ```csharp theme={null} async Task IsLoggedIn() { return await passport.HasCredentialsSaved(); } ``` ```cpp theme={null} // Add to your subsystem: public: void CheckStoredCredentials() { auto Passport = GetPassport(); if (!Passport) { return; } Passport->HasStoredCredentials(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnCredentialsChecked)); } private: void OnCredentialsChecked(FImmutablePassportResult Result) { if (Result.Success) { UE_LOG(LogTemp, Log, TEXT("Stored Credentials Found - Proceeding to Login")); } else { UE_LOG(LogTemp, Log, TEXT("No Stored Credentials - New Login Required")); } } ```
### Get Access Token For authenticated API calls to your backend: `getAccessToken()` returns a guaranteed-fresh access token. If the current token is valid it returns immediately; if expired, it triggers a server-side refresh and blocks until the fresh token is available. Multiple concurrent calls share a single refresh request. ### SWR Fetcher ```tsx theme={null} import useSWR from 'swr'; import { useImmutableSession } from '@imtbl/auth-next-client'; function useProfile() { const { getAccessToken, isAuthenticated } = useImmutableSession(); return useSWR( isAuthenticated ? '/passport-profile/v1/profile' : null, async (path) => { const token = await getAccessToken(); const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` }, }); return res.json(); }, ); } ``` ### Event Handler ```tsx theme={null} import { useImmutableSession } from '@imtbl/auth-next-client'; function ClaimRewardButton({ questId }: { questId: string }) { const { getAccessToken } = useImmutableSession(); const handleClaim = async () => { const token = await getAccessToken(); await fetch('/v1/quests/claim', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ questId }), }); }; return ; } ``` ### Periodic Polling ```tsx theme={null} import useSWR from 'swr'; import { useImmutableSession } from '@imtbl/auth-next-client'; function ActivityFeed() { const { getAccessToken, isAuthenticated } = useImmutableSession(); return useSWR( isAuthenticated ? '/v1/activities' : null, async (path) => { const token = await getAccessToken(); const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` }, }); return res.json(); }, { refreshInterval: 10000 }, ); } ``` ```typescript theme={null} const accessToken = await auth.getAccessToken(); // Use in API requests const response = await fetch('/api/user/inventory', { headers: { Authorization: `Bearer ${accessToken}`, }, }); ``` ```cpp theme={null} // Add to your subsystem: private: void OnLoginComplete(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Login Failed: %s"), *Result.Error); return; } UE_LOG(LogTemp, Log, TEXT("Login Successful")); auto Passport = GetPassport(); Passport->GetAccessToken(UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult AccessTokenResult) { if (AccessTokenResult.Success) { FString AccessToken = UImmutablePassport::GetResponseResultAsString(AccessTokenResult.Response); // Use access token for authenticated API requests to your backend UE_LOG(LogTemp, Log, TEXT("Access Token: %s"), *AccessToken); } })); } ``` ### Token Refresh #### Automatic Refresh The server-side JWT callback automatically refreshes tokens when the access token expires. This happens transparently during any session access. #### Force Refresh After operations that update user claims on the identity provider (such as zkEVM wallet registration), force a token refresh to get the updated data: ```tsx theme={null} const { getUser } = useImmutableSession(); async function handleRegistration() { // ... after zkEVM registration completes const user = await getUser(true); console.log("Updated zkEvm:", user?.zkEvm); } ``` ### Get ID Token The ID token contains user identity claims: The ID token is not stored in the session cookie (to stay within CDN header size limits). Use `getUser()` to access it -- the client persists it in `localStorage` automatically. ```tsx theme={null} 'use client'; import { useImmutableSession } from '@imtbl/auth-next-client'; function GetIdToken() { const { getUser } = useImmutableSession(); async function handleGetToken() { const user = await getUser(); console.log('ID Token:', user?.idToken); } return ; } ``` ```typescript theme={null} const idToken = await auth.getIdToken(); // JWT containing user claims (sub, email, etc.) ``` ```cpp theme={null} // Add to your subsystem: private: void OnLoginComplete(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Login Failed: %s"), *Result.Error); return; } UE_LOG(LogTemp, Log, TEXT("Login Successful")); auto Passport = GetPassport(); Passport->GetIdToken(UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult IdTokenResult) { if (IdTokenResult.Success) { FString IdToken = UImmutablePassport::GetResponseResultAsString(IdTokenResult.Response); // JWT containing user claims (sub, email, etc.) FString TruncatedToken = IdToken.Left(50) + TEXT("..."); UE_LOG(LogTemp, Log, TEXT("ID Token: %s"), *TruncatedToken); } })); } ``` ## Logout The `useLogout` hook performs federated logout -- it clears both the local NextAuth session and the upstream Immutable/Auth0 session by redirecting to the logout endpoint. This is important when using social logins like Google: without federated logout, the auth server caches the social session, so users clicking "Login" again would be automatically logged in with the same account instead of being prompted to choose. ```tsx theme={null} 'use client'; import { useLogout, useImmutableSession } from '@imtbl/auth-next-client'; function LogoutButton() { const { logout, isLoggingOut, error } = useLogout(); const { isAuthenticated } = useImmutableSession(); if (!isAuthenticated) return null; return ( <> {error &&

{error}

} ); } ``` To customize the logout redirect URI: ```tsx theme={null} const { logout } = useLogout(); await logout({ clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!, logoutRedirectUri: `${window.location.origin}/logged-out`, }); ```
Default behavior - logs out without redirecting. ```typescript theme={null} async function logout() { try { await passportInstance.logout(); } catch (error) { console.error('Logout failed:', error); } } ``` ### Logout with Redirect To redirect users after logout, configure `logoutMode: 'redirect'` at initialization, then call logout: ```typescript theme={null} // 1. Configure redirect mode const passportInstance = new passport.Passport({ baseConfig: { environment: Environment.SANDBOX, publishableKey: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY, }, clientId: process.env.NEXT_PUBLIC_CLIENT_ID, redirectUri: 'http://localhost:3000/redirect', logoutMode: 'redirect', logoutRedirectUri: 'http://localhost:3000/logout', audience: 'platform_api', scope: 'openid offline_access email transact', }); // 2. Call logout - will redirect to logoutRedirectUri async function logout() { try { await passportInstance.logout(); } catch (error) { console.error('Logout failed:', error); } } ``` ```csharp theme={null} async void Logout() { try { // hardLogout: true = clear browser session (default), false = SDK only await Passport.Instance.Logout(hardLogout: true); } catch (Exception ex) { Debug.LogError($"Logout failed: {ex.Message}"); } } ``` **Hard vs Soft Logout:** * `hardLogout: true` (default): Clears SDK session and browser cookies (recommended) * `hardLogout: false`: Clears SDK session only; browser session persists ```cpp theme={null} // Add to your subsystem: public: void PerformLogout() { auto Passport = GetPassport(); if (!Passport) { return; } // DoHardLogout: true = clear browser session, false = SDK only Passport->Logout ( true, // DoHardLogout UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnLogoutComplete) ); } private: void OnLogoutComplete(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Logout Failed: %s"), *Result.Error); return; } UE_LOG(LogTemp, Log, TEXT("Logged Out Successfully")); } ``` **Hard vs Soft Logout:** * `DoHardLogout = true`: Clears SDK session and browser cookies (recommended) * `DoHardLogout = false`: Clears SDK session only; browser session persists
### Error Handling **Common Logout Issues:** | Error | Cause | Solution | | ------------------------- | ------------------------------ | ------------------------------------------ | | Logout callback timeout | Silent logout page didn't load | Verify logoutRedirectUri is accessible | | Wallet still connected | Cleanup order wrong | Disconnect wallet before passport.logout() | | Session persists (Unreal) | Soft logout used | Set DoHardLogout: true | **Example (TypeScript):** ```typescript theme={null} try { await passportInstance.logout(); } catch (error) { console.error('Logout failed:', error); // Fallback: clear local state anyway clearUserState(); } ``` ## Backend JWT Validation Validate Passport JWTs on your server to secure API endpoints. ### Node.js with jose ```typescript theme={null} import { createRemoteJWKSet, jwtVerify } from 'jose'; // Create JWKS client (cache this) const JWKS = createRemoteJWKSet( new URL('https://auth.immutable.com/.well-known/jwks.json') ); export async function validatePassportToken(token: string) { try { const { payload } = await jwtVerify(token, JWKS, { issuer: 'https://auth.immutable.com/', audience: 'platform_api', }); return { valid: true, userId: payload.sub, email: payload.email, }; } catch (error) { return { valid: false, error: error.message }; } } ``` ### Express Middleware ```typescript theme={null} import { Request, Response, NextFunction } from 'express'; export async function requireAuth( req: Request, res: Response, next: NextFunction ) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing token' }); } const token = authHeader.slice(7); const result = await validatePassportToken(token); if (!result.valid) { return res.status(401).json({ error: 'Invalid token' }); } req.user = { id: result.userId, email: result.email }; next(); } // Usage app.get('/api/inventory', requireAuth, (req, res) => { const userId = req.user.id; // Fetch inventory for this user }); ``` ### JWT Claims Reference | Claim | Type | Description | | ---------------- | ------- | ------------------------------------ | | `sub` | string | Unique Passport user ID | | `iss` | string | Always `https://auth.immutable.com/` | | `aud` | string | Your audience (e.g., `platform_api`) | | `exp` | number | Expiration timestamp | | `iat` | number | Issued at timestamp | | `email` | string | User's email (if scope granted) | | `email_verified` | boolean | Whether email is verified | ## Error Handling Handle authentication errors gracefully to provide better user experience: Check the session `error` field for token-level issues and use try/catch around `getAccessToken()`: ```tsx theme={null} 'use client'; import { useImmutableSession } from '@imtbl/auth-next-client'; import { signOut } from 'next-auth/react'; function ProtectedContent() { const { session, isAuthenticated, getAccessToken } = useImmutableSession(); if (session?.error === 'RefreshTokenError') { return (

Your session has expired. Please sign in again.

); } if (!isAuthenticated) { return

Please sign in to continue.

; } const handleFetch = async () => { try { const token = await getAccessToken(); await fetch('/api/data', { headers: { Authorization: `Bearer ${token}` }, }); } catch (error) { console.error('Failed to get access token:', error); } }; return ; } ``` | Session Error | Description | Action | | --------------------- | --------------------- | ------------------------------------------- | | `"TokenExpired"` | Access token expired | Handled automatically by `getAccessToken()` | | `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
```typescript theme={null} import { PassportError, PassportErrorType } from '@imtbl/auth'; async function handleLogin() { try { await auth.login(); console.log('Login successful'); } catch (error) { if (error instanceof PassportError) { switch (error.type) { case PassportErrorType.AUTHENTICATION_ERROR: console.error('Login failed:', error.message); break; case PassportErrorType.USER_REGISTRATION_ERROR: console.error('User registration failed:', error.message); break; case PassportErrorType.WALLET_CONNECTION_ERROR: console.error('Wallet connection failed:', error.message); break; default: console.error('Unknown error:', error); } } throw error; } } ```
### Common Error Types | Error Type | Description | Recommended Action | | ------------------------- | ------------------------------ | ----------------------------------------------- | | `AUTHENTICATION_ERROR` | Login or authentication failed | Ask user to try again or use different provider | | `USER_REGISTRATION_ERROR` | User registration failed | Check if user already exists or retry | | `WALLET_CONNECTION_ERROR` | Failed to connect wallet | Retry connection or check network | | `INVALID_CONFIGURATION` | Invalid Passport configuration | Verify `clientId` and `redirectUri` | ## Next Steps Integrate with Next.js, Vite, and other frameworks Connect wallet, send transactions, and sign messages Understand the security model # Passport Clients Source: https://docs.immutable.com/docs/products/passport/clients Configure Passport authentication clients for your game or application ## Create a Passport Client Create client in [Hub](https://hub.immutable.com): **Tools** → **Passport** → **Add Client** **Application Type:** Native (Unity/Unreal) or Web (browser) **Redirect URLs:** Match `redirectUri` in code **Logout URLs:** Match `logoutRedirectUri` in code ## Redirect URI Configuration ### Web Applications | Environment | Example | | ----------- | -------------------------------- | | Development | `http://localhost:3000/callback` | | Production | `https://yourgame.com/callback` | ### Native Applications (Unity/Unreal) | Platform | Example | | -------------------- | ------------------ | | Mobile (iOS/Android) | `myapp://callback` | | Desktop | `myapp://callback` | ## Multiple Environments ``` http://localhost:3000/callback # Local development https://staging.yourgame.com/callback # Staging https://yourgame.com/callback # Production ``` URIs must be exact matches (no wildcards). ## Client Types ### Native Clients For Unity, Unreal, mobile apps: * Uses PKCE for security * Custom URL schemes for redirects * No client secret required ### Web Clients For browser applications: * Standard OAuth 2.0 flow * HTTPS redirect URIs * CORS origins configuration ## Next Steps Login flows How Passport works # Gas Sponsorship Source: https://docs.immutable.com/docs/products/passport/gas-sponsorship Understanding gas sponsorship on Immutable zkEVM ## Overview Immutable sponsors gas costs for all Passport users, enabling "Gas Free for Gamers"—seamless blockchain experiences where players never encounter transaction fees. ## What is Gas Sponsorship? Exposing gas costs to end users creates significant user experience friction in blockchain games. Players must acquire cryptocurrency, understand transaction fees, and manage wallet balances before they can play. Gas sponsorship eliminates this friction entirely. Immutable sponsors transaction costs so players can trade, mint, transfer, and play without ever seeing blockchain fees. Studios treat gas sponsorship as an operational expense—similar to server infrastructure costs. ## Why "Gas Free for Gamers"? Gas sponsorship delivers measurable business benefits: * **Higher retention rates** - Players don't abandon due to transaction fee friction * **Better conversion** - No cryptocurrency barrier to entry * **Seamless onboarding** - Web2-like experience for Web3 games ## Eligibility Gas sponsorship is available exclusively for **Immutable Passport users**. ## Getting Started Set up Passport to enable gas sponsorship Mint assets with automatic gas sponsorship Monitor gas costs in [Immutable Hub](/docs/guides/advanced-setup/api-keys) Build and send sponsored transactions ## Next Steps Build wallet features Add funding options for players Mint NFTs with sponsored gas Learn about Immutable Chain gas # Passport Source: https://docs.immutable.com/docs/products/passport/overview Seamless authentication and embedded wallets for games Passport is Immutable's identity and wallet solution. Players sign in with familiar accounts (Google, Apple, email) and get a blockchain wallet that works across all Immutable games—no extensions, no seed phrases, no friction. Login flows, sessions, and identity management Transactions, signing, and blockchain interactions Seamless in-game transactions for native clients Security model and key management ## Why Passport? Players sign in with Google, Apple, or email—accounts they already have. No wallet downloads, no browser extensions, no seed phrases. The wallet is created automatically. Unlike other embedded wallets that create a new address per app, Passport gives users one wallet across all Immutable games. Assets, identity, and reputation stay unified. Gain instant access to millions of players who already have a Passport account. Users log in on any device to access their wallet. No seed phrase backup or manual imports—just authenticate with the same social account. Pre-approved transactions let whitelisted game actions execute instantly without popups. Critical for real-time gameplay. Users own their keys. Immutable cannot move funds without user consent—we're a co-signer for security policies, not a custodian. See detailed comparison with MetaMask, Privy, and other wallet solutions → ## Quick Start Install the SDK and connect a user's wallet in one step: ```bash theme={null} npm install @imtbl/wallet ``` ```typescript theme={null} import { connectWallet } from '@imtbl/wallet'; // Connect wallet (prompts login if needed) const provider = await connectWallet(); // Get the user's wallet address const accounts = await provider.request({ method: 'eth_requestAccounts' }); console.log('Wallet:', accounts[0]); ``` That's it. The `connectWallet` function handles authentication automatically—users see Passport's login screen if they're not already signed in. Some features—such as customizing the login experience, using Passport inside a game, or accessing user identity—require setting up an Auth client. See the [Authentication](/docs/products/passport/authentication) guide for details. For Unity and Unreal integration, see the [Authentication](/docs/products/passport/authentication) guide. ## Chain Support | Chain | Network ID | Status | | ----------------- | ---------- | ------------------------------- | | Immutable Mainnet | 13371 | Full Support | | Immutable Testnet | 13473 | Full Support | | Ethereum Mainnet | 1 | Limited support, ejections only | | Other EVM Chains | Various | Support coming in future | Do not send funds to your Passport address on unsupported chains. If you have sent funds on Ethereum mainnet, you can use the wallet functionality on [Immutable Play](https://play.immutable.com) to eject those funds to Immutable Chain. If you have sent funds to your Passport address on any other chain, your funds will be stuck indefinitely. Please raise a ticket with [Player Support](https://play.immutable.com), but this will not be able to be resolved until Passport supports all EVM chains. ## Account Management ### Deleting your Passport account **Important**: Once you delete your Passport account, the associated wallet and all assets within it will become **irrecoverable**. Be sure to transfer all assets you wish to keep before initiating the account deletion process. Note that this deletion process may take up to 30 days to complete. Please refer to our [privacy policy](https://www.immutable.com/privacy-policy) for further information. #### Before deleting: Transfer your assets To transfer your assets from Passport to another wallet: Visit a marketplace, such as [TokenTrove](https://tokentrove.com) Specify the destination wallet address where you'd like to transfer your assets Complete the transfer process for all assets you want to keep Verify that all assets have been successfully transferred from your Passport wallet #### How to delete your account Visit [Immutable Play](https://play.immutable.com) Click the Help widget in the bottom-left corner Start a chat with the AI agent and request Passport account deletion Complete the guided steps provided by the support system ## Next Steps Implement login, logout, and session management Send transactions and sign messages # Pre-Approved Transactions Source: https://docs.immutable.com/docs/products/passport/pre-approved-transactions Enable seamless in-game transactions without confirmation popups for immersive gameplay Pre-approved transactions allow players to execute in-game blockchain transactions—such as crafting, trading, and transferring assets—without disruptive confirmation popups. This feature keeps players immersed in gameplay while maintaining the security benefits of blockchain ownership. Pre-approved transactions are only available in **native clients** (Unity and Unreal games on mobile and desktop). Web transactions currently always require explicit confirmation popups. ## Prerequisites Before using pre-approved transactions, ensure: 1. **User Authentication**: User must be [authenticated](/docs/products/passport/authentication) with Passport 2. **Wallet Initialization**: Call `ConnectEvm()` to initialize the zkEVM wallet provider 3. **Native Client**: Using Unity or Unreal SDK (not TypeScript/web) 4. **Contracts Linked**: Your contracts must be linked in Immutable Hub ## How It Works Pre-approved transactions eliminate confirmation popups for in-game blockchain actions: 1. **One-time consent** - Users approve your game once for seamless transactions 2. **Automatic execution** - All future transactions to linked contracts execute instantly 3. **Gas-free** - Immutable sponsors transaction fees automatically This keeps players immersed in gameplay while maintaining blockchain security. ## Supported Transactions All transactions to **linked contracts** (ERC-20, ERC-721, ERC-1155) are automatically pre-approved: * Transfers, mints, burns * Custom contract interactions (crafting, trading, etc.) **Not supported:** Native IMX transfers require user confirmation. ## Enabling Pre-Approved Transactions ### Step 1: Link Your Contracts in Hub Your contracts must be linked in Immutable Hub to enable pre-approved transactions. Complete guide to linking contracts, verification process, and API details Linked contracts are automatically eligible for pre-approved transactions and gas sponsorship. ### Step 2: Create a Native Passport Client Pre-approved transactions require a **Native** (not Web) Passport client. Step-by-step guide to creating a Passport client in Hub Choose "Native" application type when creating your client. Pre-approved transactions will be available for all native clients in your organization. ### Step 3: Implementation Pre-approval happens automatically when you use standard Passport transaction methods with linked contracts. See [Unity SDK](/docs/sdks/unity/overview) or [Unreal SDK](/docs/sdks/unreal/overview) for implementation details. ## Next Steps Link and verify your contracts in Immutable Hub Get started with Unity SDK integration Get started with Unreal SDK integration # Wallet Source: https://docs.immutable.com/docs/products/passport/wallet Every Passport user has an embedded wallet on Immutable Chain. This guide covers wallet operations for developers integrating Passport into games and applications. **For end users**: Players can manage their wallet, view balances, and browse transaction history at [Immutable Play](https://play.immutable.com). Direct your users there for wallet management features. ## Overview This page covers essential wallet operations including retrieving wallet addresses, checking native IMX and ERC-20 token balances, sending transactions (transfers, contract interactions, NFTs), signing messages (ERC-191 personal sign and EIP-712 typed data), and error handling patterns. ## Prerequisites User must be authenticated with Passport before using wallet operations **Web vs Native**: Some features like [pre-approved transactions](/docs/products/passport/pre-approved-transactions) require Unity/Unreal native clients and cannot be used in web browsers. ## Wallet Connect Connect the EVM provider and request account access. Required for all wallet operations including [Checkout](/docs/products/checkout/overview) wallet funding and [Orderbook](/docs/products/orderbook/overview) trading operations. Pass `getUser` from `useImmutableSession` to `connectWallet` so the wallet uses your server-managed session instead of its own OAuth flow. ```tsx theme={null} 'use client'; import { useImmutableSession } from '@imtbl/auth-next-client'; import { connectWallet, type ZkEvmProvider } from '@imtbl/wallet'; import { useState } from 'react'; function WalletConnect() { const { isAuthenticated, getUser } = useImmutableSession(); const [address, setAddress] = useState(null); async function handleConnect() { const provider = await connectWallet({ getUser: isAuthenticated ? getUser : undefined, clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!, }) as ZkEvmProvider; const accounts = await provider.request({ method: 'eth_requestAccounts' }); setAddress(accounts[0]); } return (
{address ? (

Wallet connected: {address}

) : ( )}
); } ``` When `getUser` is provided, the wallet skips its own OAuth flow and relies on the server-managed session. This avoids duplicate login prompts and keeps token refresh in one place. Pass `undefined` when the user is not authenticated to allow the wallet to use its own login flow.
```typescript theme={null} // Assuming Passport is initialized and user is logged in // 1. Connect EVM provider const provider = passportInstance.connectEvm(); // 2. Request accounts (connect wallet) const accounts = await provider.request({ method: 'eth_requestAccounts' }); const address = accounts[0]; console.log('Wallet connected:', address); ``` The `connectEvm()` method returns an [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) compatible provider that can be used with libraries like [ethers.js](https://docs.ethers.org/). ```csharp theme={null} // Assuming Passport is initialized and user is logged in // 1. Connect EVM provider await passport.ConnectEvm(); // 2. Request accounts (connect wallet) var accounts = await passport.ZkEvmRequestAccounts(); string address = accounts[0]; Debug.Log($"Wallet connected: {address}"); ``` Call `ConnectEvm()` once after login, then use `ZkEvmRequestAccounts()` to get the wallet address. This initializes the zkEVM provider for all subsequent wallet operations. ```cpp theme={null} // Add to your subsystem: public: void ConnectWallet() { auto Passport = GetPassport(); if (!Passport) { return; } // 1. Connect EVM provider Passport->ConnectEvm(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnWalletEvmConnected)); } private: void OnWalletEvmConnected(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("ConnectEvm Failed: %s"), *Result.Error); return; } auto Passport = GetPassport(); if (!Passport) { return; } // 2. Request accounts (connect wallet) Passport->ZkEvmRequestAccounts(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnWalletAccountsReceived)); } void OnWalletAccountsReceived(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Request Accounts Failed: %s"), *Result.Error); return; } const auto& RequestAccountsData = FImmutablePassportZkEvmRequestAccountsData::FromJsonObject(Result.Response.JsonObject); if (!RequestAccountsData.IsSet()) { UE_LOG(LogTemp, Error, TEXT("Failed to parse accounts data")); return; } const auto& Accounts = RequestAccountsData->accounts; for (int Index = 0; Index < Accounts.Num(); Index++) { UE_LOG(LogTemp, Log, TEXT("Account[%d]: %s"), Index, *Accounts[Index]); } } void OnLoginComplete(FImmutablePassportResult Result) { if (!Result.Success) return; UE_LOG(LogTemp, Log, TEXT("Login Successful")); ConnectWallet(); } ``` Follow the sequence: ConnectEvm → ZkEvmRequestAccounts. Use `FImmutablePassportZkEvmRequestAccountsData::FromJsonObject` to parse the response.
**Important**: Before connecting the wallet, ensure you have [initialized Passport and logged in the user](/docs/products/passport/authentication). Then complete these steps: 1. Connect EVM provider (`connectEvm()` / `ConnectEvm()`) 2. Request accounts (`eth_requestAccounts` / `ZkEvmRequestAccounts()`) **Next.js:** Once you have the provider from `connectWallet({ getUser })` (shown in the Next.js tab above), all wallet operations below work the same way as the TypeScript examples -- the provider is the same [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) compatible `ZkEvmProvider`. Use the TypeScript tab code with your Next.js provider. ## Configuration The `connectWallet` function accepts configuration options to customize the wallet provider. ### Custom Client ID ```typescript theme={null} const provider = await connectWallet({ clientId: 'your-client-id', }); ``` ### Chain Selection ```typescript theme={null} import { connectWallet, IMMUTABLE_ZKEVM_MAINNET_CHAIN, IMMUTABLE_ZKEVM_TESTNET_CHAIN, } from '@imtbl/wallet'; const provider = await connectWallet({ chains: [IMMUTABLE_ZKEVM_MAINNET_CHAIN], }); ``` ### Popup Overlay Options ```typescript theme={null} const provider = await connectWallet({ popupOverlayOptions: { disableGenericPopupOverlay: true, disableBlockedPopupOverlay: true, }, }); ``` ### All Options | Option | Type | Default | Description | | --------------------- | --------------------- | -------------------- | --------------------------------------------- | | `clientId` | `string` | Auto-detected | Immutable client ID | | `chains` | `ChainConfig[]` | `[testnet, mainnet]` | Chain configurations | | `initialChainId` | `number` | First chain | Initial chain to connect to | | `popupOverlayOptions` | `PopupOverlayOptions` | - | Options for login popup overlays | | `announceProvider` | `boolean` | `true` | Whether to announce via EIP-6963 | | `feeTokenSymbol` | `string` | `'IMX'` | Preferred token symbol for relayer fees | | `getUser` | `GetUserFunction` | Internal | Custom function for external auth integration | ### Using @imtbl/auth (Direct) For applications that manage authentication outside of Next.js, wrap the `Auth` class in a `getUser` function: ```typescript theme={null} import { connectWallet } from '@imtbl/wallet'; import { Auth } from '@imtbl/auth'; const auth = new Auth({ clientId: 'your-client-id', redirectUri: 'https://your-app.com/callback', }); const getUser = async (forceRefresh?: boolean) => { if (forceRefresh) { return auth.forceUserRefresh(); } return auth.getUser(); }; const provider = await connectWallet({ getUser }); ``` ## Chain Configuration ### Chain Constants ```typescript theme={null} import { IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID, // 13371 IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID, // 13473 } from '@imtbl/wallet'; ``` ### Presets ```typescript theme={null} import { IMMUTABLE_ZKEVM_MAINNET_CHAIN, IMMUTABLE_ZKEVM_TESTNET_CHAIN, DEFAULT_CHAINS, } from '@imtbl/wallet'; // Testnet only const provider = await connectWallet({ chains: [IMMUTABLE_ZKEVM_TESTNET_CHAIN], }); // Mainnet only const provider = await connectWallet({ chains: [IMMUTABLE_ZKEVM_MAINNET_CHAIN], }); // Both (default) const provider = await connectWallet({ chains: DEFAULT_CHAINS, }); ``` Spread-friendly presets are also available: ```typescript theme={null} import { IMMUTABLE_ZKEVM_MAINNET, IMMUTABLE_ZKEVM_TESTNET, IMMUTABLE_ZKEVM_MULTICHAIN, } from '@imtbl/wallet'; const provider = await connectWallet({ ...IMMUTABLE_ZKEVM_MAINNET, }); ``` ### Custom Chain Configuration ```typescript theme={null} import type { ChainConfig } from '@imtbl/wallet'; const customChain: ChainConfig = { chainId: 13473, name: 'Immutable zkEVM Testnet', rpcUrl: 'https://rpc.testnet.immutable.com', relayerUrl: 'https://api.sandbox.immutable.com/relayer-mr', apiUrl: 'https://api.sandbox.immutable.com', passportDomain: 'https://passport.sandbox.immutable.com', }; ``` ## Get Wallet Address ```typescript theme={null} // First, initialize the zkEVM provider const provider = await connectWallet({ auth }); // Then get the wallet address const accounts = await provider.request({ method: 'eth_requestAccounts' }); const address = accounts[0]; console.log('Wallet address:', address); ``` ```csharp theme={null} // First, initialize the zkEVM provider (call once after login) await passport.ConnectEvm(); // Then get the wallet address var accounts = await passport.ZkEvmRequestAccounts(); string address = accounts[0]; Debug.Log($"Wallet address: {address}"); ``` ```cpp theme={null} // Add to your subsystem: public: void GetWalletAddress() { auto Passport = GetPassport(); if (!Passport) { return; } // First, initialize the zkEVM provider (call once after login) Passport->ConnectEvm(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnEvmConnected)); } private: void OnEvmConnected(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("ConnectEvm Failed: %s"), *Result.Error); return; } auto Passport = GetPassport(); if (!Passport) { return; } // Then get the wallet address Passport->ZkEvmRequestAccounts(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnAccountsReceived)); } void OnAccountsReceived(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Request Accounts Failed: %s"), *Result.Error); return; } const auto& RequestAccountsData = FImmutablePassportZkEvmRequestAccountsData::FromJsonObject(Result.Response.JsonObject); if (!RequestAccountsData.IsSet()) { UE_LOG(LogTemp, Error, TEXT("Failed to parse accounts data")); return; } if (RequestAccountsData->accounts.Num() > 0) { FString Address = RequestAccountsData->accounts[0]; UE_LOG(LogTemp, Log, TEXT("Wallet Address: %s"), *Address); } } void OnWalletAccountsReceived(FImmutablePassportResult Result) { if (!Result.Success) return; UE_LOG(LogTemp, Log, TEXT("Wallet Connected")); GetWalletAddress(); } ``` ## Linked Addresses Users can link external wallets (like MetaMask, WalletConnect, etc.) to their Passport account, allowing them to use the same identity across multiple wallets. ```typescript theme={null} import { getLinkedAddresses } from '@imtbl/wallet'; import { ImmutableConfiguration } from '@imtbl/config'; import { Environment } from '@imtbl/config'; // Initialize API client const apiClient = new ImmutableConfiguration({ environment: Environment.SANDBOX, }).getMultiRollupApiClients(); // Get all wallets linked to the current Passport account const linkedAddresses = await getLinkedAddresses(auth, apiClient); console.log('Linked addresses:', linkedAddresses); // Returns: ['0x123...', '0x456...'] ``` ```csharp theme={null} List addresses = await Passport.Instance.GetLinkedAddresses(); if (addresses.Count > 0) { Debug.Log("Linked addresses:"); foreach (string address in addresses) { Debug.Log($" {address}"); } } else { Debug.Log("No linked addresses"); } ``` ```cpp theme={null} // Add to your subsystem: public: void GetLinkedAddresses() { auto Passport = GetPassport(); if (!Passport) { return; } Passport->GetLinkedAddresses(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnLinkedAddressesReceived)); } private: void OnLinkedAddressesReceived(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Get Linked Addresses Failed: %s"), *Result.Error); return; } TArray LinkedAddresses = UImmutablePassport::GetResponseResultAsStringArray(Result.Response); if (LinkedAddresses.Num() > 0) { UE_LOG(LogTemp, Log, TEXT("Found %d Linked Address(es)"), LinkedAddresses.Num()); for (int Index = 0; Index < LinkedAddresses.Num(); Index++) { UE_LOG(LogTemp, Log, TEXT(" Linked[%d]: %s"), Index, *LinkedAddresses[Index]); } } else { UE_LOG(LogTemp, Log, TEXT("No Linked Addresses Found")); } } void OnWalletAccountsReceived(FImmutablePassportResult Result) { if (!Result.Success) return; UE_LOG(LogTemp, Log, TEXT("Wallet Connected")); GetLinkedAddresses(); } ``` **Important**: `ConnectEvm()` must be called once after login to initialize the zkEVM provider before calling `ZkEvmRequestAccounts()` or any other wallet operations. ## Link External Wallet Link an external wallet (such as MetaMask or a hardware wallet) to the user's Passport account. This requires a signature from the external wallet to prove ownership. ```typescript theme={null} import { linkExternalWallet } from '@imtbl/wallet'; import type { LinkWalletParams, LinkedWallet } from '@imtbl/wallet'; const walletAddress = '0x...'; const nonce = 'unique-nonce-123'; const message = `Link wallet ${walletAddress} with nonce ${nonce}`; // Sign the message with the external wallet (e.g., using ethers.js or viem) const signature = await externalWallet.signMessage(message); const params: LinkWalletParams = { type: 'metamask', walletAddress, signature, nonce, }; const linkedWallet: LinkedWallet = await linkExternalWallet( auth, apiClient, params ); console.log('Linked wallet:', linkedWallet.address); ``` ### Linking Errors | Error Code | Description | | -------------------- | ------------------------------------------- | | `ALREADY_LINKED` | This wallet is already linked to an account | | `MAX_WALLETS_LINKED` | Maximum number of linked wallets reached | | `DUPLICATE_NONCE` | The nonce has already been used | | `VALIDATION_ERROR` | Invalid signature or parameters | ## Check Balances Native IMX and ERC-20 Token Balances ```typescript theme={null} import { BrowserProvider, formatEther, Contract } from 'ethers'; const provider = connectWallet({ auth }); const accounts = await provider.request({ method: 'eth_requestAccounts' }); const address = accounts[0]; const balance = await provider.request({ method: 'eth_getBalance', params: [address, 'latest'] }); console.log('IMX Balance:', formatEther(balance), 'IMX'); // Get ERC-20 token balance const erc20Abi = [ 'function balanceOf(address owner) view returns (uint256)', 'function decimals() view returns (uint8)', 'function symbol() view returns (string)', ]; const tokenAddress = '0x...'; // Your ERC-20 token contract address const ethersProvider = new BrowserProvider(provider); const tokenContract = new Contract(tokenAddress, erc20Abi, ethersProvider); const tokenBalance = await tokenContract.balanceOf(address); const decimals = await tokenContract.decimals(); const symbol = await tokenContract.symbol(); console.log(`Token Balance: ${formatEther(tokenBalance)} ${symbol}`); ``` ```csharp theme={null} // Get native IMX balance using Passport's zkEVM provider string balance = await passport.ZkEvmGetBalance(playerAddress); Debug.Log($"Balance: {balance} wei"); ``` The `ZkEvmGetBalance` method returns balance in wei as a string. ```cpp theme={null} // Add to your subsystem: public: void GetWalletBalance(const FString& WalletAddress) { auto Passport = GetPassport(); if (!Passport) { return; } FImmutablePassportZkEvmGetBalanceData BalanceData; BalanceData.address = WalletAddress; BalanceData.blockNumberOrTag = TEXT("latest"); // Optional: defaults to "latest" Passport->ZkEvmGetBalance(BalanceData, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnBalanceReceived)); } private: void OnBalanceReceived(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Get Balance Failed: %s"), *Result.Error); return; } FString Balance = UImmutablePassport::GetResponseResultAsString(Result.Response); UE_LOG(LogTemp, Log, TEXT("Wallet Balance (wei): %s"), *Balance); } void OnWalletAccountsReceived(FImmutablePassportResult Result) { if (!Result.Success) return; const auto& Data = FImmutablePassportZkEvmRequestAccountsData::FromJsonObject(Result.Response.JsonObject); if (Data.IsSet() && Data->accounts.Num() > 0) { GetWalletBalance(Data->accounts[0]); } } ``` The `ZkEvmGetBalance` method returns balance in wei as a hex string. ## Send Transactions **Wei Conversion:** 1 IMX = 10^18 wei. For TypeScript, use `parseUnits('1', 18)` from ethers or viem. Unity/Unreal require string values in wei. ### Send Transaction with Confirmation **Recommended for critical operations** - Waits for blockchain confirmation before returning. ```typescript theme={null} import { BrowserProvider } from 'ethers'; // Get the provider from Passport const provider = await connectWallet({ auth }); // Wrap provider in ethers BrowserProvider const browserProvider = new BrowserProvider(provider); // Get the signer (represents the user's wallet) const signer = await browserProvider.getSigner(); // Send transaction using signer const tx = await signer.sendTransaction({ to: '0xRecipient...', value: '1500000000000000000', // 1.5 IMX in wei }); // Wait for blockchain confirmation const receipt = await tx.wait(); console.log('Transaction confirmed!'); console.log('Hash:', receipt.hash); console.log('Status:', receipt.status === 1 ? 'Success' : 'Failed'); ``` ```csharp theme={null} using Immutable.Passport.Model; TransactionRequest request = new TransactionRequest() { to = address, value = amount, data = data } TransactionReceiptResponse response = await passport.ZkEvmSendTransactionWithConfirmation(request); switch (response.status) { case "1": // Successful break; case "0": // Failed break; } ``` ```cpp theme={null} // Add to your subsystem: public: void SendTransactionWithConfirmation(const FString& ToAddress, const FString& ValueInWei, const FString& Data = TEXT("0x")) { auto Passport = GetPassport(); if (!Passport) { UE_LOG(LogTemp, Error, TEXT("Passport not available")); return; } FImtblTransactionRequest TransactionRequest; TransactionRequest.to = ToAddress; TransactionRequest.value = ValueInWei; TransactionRequest.data = Data; Passport->ZkEvmSendTransactionWithConfirmation(TransactionRequest, UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult Result) { if (Result.Success) { UE_LOG(LogTemp, Log, TEXT("Transaction Confirmed")); } else { UE_LOG(LogTemp, Error, TEXT("Transaction Confirmation Failed: %s"), *Result.Error); } })); } ``` **When to use:** Critical operations like transfers, mints, or state changes. Ensures transaction succeeded before updating game state. ```typescript theme={null} import { BrowserProvider, ethers } from 'ethers'; // Setup provider and signer const provider = await connectWallet({ auth }); const browserProvider = new BrowserProvider(provider); const signer = await browserProvider.getSigner(); // Define contract ABI (just the functions you need) const abi = [ 'function safeTransferFrom(address from, address to, uint256 tokenId)' ]; // Create contract instance const contract = new ethers.Contract( '0xYourNFTContract...', // Contract address abi, signer // Signer enables sending transactions ); // Call contract function - automatically sends transaction const tx = await contract.safeTransferFrom( userAddress, recipientAddress, tokenId ); // Wait for confirmation const receipt = await tx.wait(); console.log('NFT transferred!', receipt.hash); ``` *** ### Send Transaction without Confirmation **For fire-and-forget operations** - Returns transaction hash immediately without waiting. ```typescript theme={null} import { BrowserProvider } from 'ethers'; // Get the provider from Passport const provider = await connectWallet({ auth }); // Wrap provider in ethers BrowserProvider const browserProvider = new BrowserProvider(provider); // Get the signer (represents the user's wallet) const signer = await browserProvider.getSigner(); // Send transaction - returns immediately without waiting const tx = await signer.sendTransaction({ to: '0xRecipient...', value: '1500000000000000000', // 1.5 IMX in wei }); console.log('Transaction sent:', tx.hash); // User can continue - transaction confirms in background ``` **Fire-and-Forget:** Returns immediately without waiting for blockchain confirmation. Use for non-critical operations where you want responsive UI (cosmetic purchases, achievements, analytics). ```csharp theme={null} using Cysharp.Threading.Tasks; using Immutable.Passport.Model; using System.Threading; async void GetTransactionReceiptStatus() { TransactionRequest request = new TransactionRequest() { to = address, value = amount, data = data } string transactionHash = await passport.ZkEvmSendTransaction(request); string? status = await PollStatus( passport, transactionHash, TimeSpan.FromSeconds(1), // Poll every one second TimeSpan.FromSeconds(10) // Stop polling after 10 seconds ); switch (status) { case "0x1": // Successful break; case "0x0": // Failed break; } } static async UniTask PollStatus(Passport passport, string transactionHash, TimeSpan pollInterval, TimeSpan timeout) { var cancellationTokenSource = new CancellationTokenSource(timeout); try { while (!cancellationTokenSource.Token.IsCancellationRequested) { TransactionReceiptResponse response = await passport.ZkEvmGetTransactionReceipt(transactionHash); if (response.status == null) { // The transaction is still being processed, poll for status again await UniTask.Delay(delayTimeSpan: pollInterval, cancellationToken: cancellationTokenSource.Token); } else { return response.status; } } } catch (OperationCanceledException) { // Task was canceled due to timeout } return null; // Timeout or could not get transaction receipt } ``` ```cpp theme={null} // Add to your subsystem: public: void SendTransaction(const FString& ToAddress, const FString& ValueInWei, const FString& Data = TEXT("0x")) { auto Passport = GetPassport(); if (!Passport) { UE_LOG(LogTemp, Error, TEXT("SendTransaction: Passport not available")); return; } FImtblTransactionRequest TransactionRequest; TransactionRequest.to = ToAddress; TransactionRequest.value = ValueInWei; TransactionRequest.data = Data; Passport->ZkEvmSendTransaction(TransactionRequest, UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult Result) { if (Result.Success) { FString TxHash = UImmutablePassport::GetResponseResultAsString(Result.Response); UE_LOG(LogTemp, Log, TEXT("Transaction Hash: %s"), *TxHash); } else { UE_LOG(LogTemp, Error, TEXT("Send Transaction Failed: %s"), *Result.Error); } })); } ``` **Important:** Don't update game state until transaction is confirmed. Use this pattern only for non-critical operations or when you have custom polling logic. **Transaction Hash ≠ Success**: Obtaining the transaction hash does not guarantee a successful transaction. To determine the transaction's status, use `ZkEvmGetTransactionReceipt` along with the transaction hash received. Follow best practices for client-side polling: set maximum attempts, polling intervals, and implement timeout handling. **When to use:** Non-critical operations or when you need custom polling/retry logic. Most games should use "with confirmation" variant for critical operations. ### NFT Transfer ```typescript theme={null} const ERC721_ABI = [{ name: 'transferFrom', type: 'function', inputs: [ { name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }, ], outputs: [], stateMutability: 'nonpayable', }] as const; export async function transferNFT( nftContract: `0x${string}`, from: `0x${string}`, to: `0x${string}`, tokenId: bigint ) { // highlight-start const hash = await walletClient.writeContract({ account: from, address: nftContract, abi: ERC721_ABI, functionName: 'transferFrom', args: [from, to, tokenId], }); // highlight-end const receipt = await publicClient.waitForTransactionReceipt({ hash }); console.log('NFT transferred! Block:', receipt.blockNumber); return receipt; } ``` ```csharp theme={null} public class NFTTransfer : MonoBehaviour { public async void Execute() { try { string receiverAddress = "0x..."; // Receiver's wallet address string tokenId = "123"; // NFT Token ID string tokenAddress = "0x..."; // NFT Contract Address UnsignedTransferRequest transferRequest = UnsignedTransferRequest.ERC721(receiverAddress, tokenId, tokenAddress); CreateTransferResponseV1 response = await Passport.Instance.ImxTransfer(transferRequest); Debug.Log($"NFT transferred successfully! Transfer ID: {response.transfer_id}"); } catch (System.Exception ex) { Debug.LogError($"Transfer failed: {ex.Message}"); } } } ``` ```cpp theme={null} // Add to your subsystem: public: void ImxTransferNFT(const FString& ReceiverAddress, const FString& TokenId, const FString& TokenAddress) { auto Passport = GetPassport(); if (!Passport) { UE_LOG(LogTemp, Error, TEXT("ImxTransfer: Passport not available")); return; } FImxTransferRequest ImxTransfer; ImxTransfer.receiver = ReceiverAddress; ImxTransfer.type = TEXT("ERC721"); ImxTransfer.tokenId = TokenId; ImxTransfer.tokenAddress = TokenAddress; Passport->ImxTransfer(ImxTransfer, UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult Result) { if (Result.Success) { UE_LOG(LogTemp, Log, TEXT("IMX NFT Transfer Successful")); } else { UE_LOG(LogTemp, Error, TEXT("IMX NFT Transfer Failed: %s"), *Result.Error); } })); } ``` ## Sign Messages ### Personal Sign (ERC-191) For authentication or simple message signing: ```typescript theme={null} // Assuming you have the Passport provider from connectEvm() const provider = connectWallet({ auth }); const accounts = await provider.request({ method: 'eth_requestAccounts' }); const address = accounts[0]; const message = 'Hello from Immutable!'; // Sign the message using personal_sign (ERC-191) const signature = await provider.request({ method: 'personal_sign', params: [message, address], }); console.log('Signature:', signature); ``` **Unity SDK Limitation:** Unity SDK does not support ERC-191 personal sign. Only EIP-712 typed data signing is supported via `ZkEvmSignTypedDataV4()`. See the Typed Data section below for message signing in Unity. **Unreal SDK Limitation:** Unreal SDK does not support ERC-191 personal sign. Only EIP-712 typed data signing is supported via `ZkEvmSignTypedDataV4()`. See the Typed Data section below for message signing in Unreal. ### Typed Data (EIP-712) For structured data signing (used by protocols like Seaport): ```typescript theme={null} // Assuming you have the Passport provider from connectEvm() const provider = connectWallet({ auth }); const accounts = await provider.request({ method: 'eth_requestAccounts' }); const address = accounts[0]; // Get chain ID const chainIdHex = await provider.request({ method: 'eth_chainId' }); const chainId = parseInt(chainIdHex, 16); // Define EIP-712 typed data structure const typedData = { domain: { name: 'My Game', version: '1', chainId, verifyingContract: address, // Your contract address }, message: { itemId: 123, price: '1000000000000000000', // 1 token in wei }, primaryType: 'Trade', types: { EIP712Domain: [ { name: 'name', type: 'string' }, { name: 'version', type: 'string' }, { name: 'chainId', type: 'uint256' }, { name: 'verifyingContract', type: 'address' }, ], Trade: [ { name: 'itemId', type: 'uint256' }, { name: 'price', type: 'uint256' }, ], }, }; // Sign typed data using eth_signTypedData_v4 const signature = await provider.request({ method: 'eth_signTypedData_v4', params: [address, JSON.stringify(typedData)], }); console.log('Typed data signature:', signature); ``` ```csharp theme={null} // Construct EIP-712 typed data as JSON string string typedDataJson = @"{ ""domain"": { ""name"": ""My Game"", ""version"": ""1"", ""chainId"": 13473, ""verifyingContract"": ""0xYourContract..."" }, ""types"": { ""Trade"": [ { ""name"": ""itemId"", ""type"": ""uint256"" }, { ""name"": ""price"", ""type"": ""uint256"" } ] }, ""primaryType"": ""Trade"", ""message"": { ""itemId"": 123, ""price"": ""10000000000000000000"" } }"; var signature = await passport.ZkEvmSignTypedDataV4(typedDataJson); Debug.Log($"Signature: {signature}"); ``` ```cpp theme={null} // Add to your subsystem: public: void SignTypedData() { auto Passport = GetPassport(); if (!Passport) { return; } FString TypedDataJson = TEXT(R"({ "domain": { "name": "My Game", "version": "1", "chainId": 13473, "verifyingContract": "0xYourContract..." }, "types": { "Trade": [ { "name": "itemId", "type": "uint256" }, { "name": "price", "type": "uint256" } ] }, "primaryType": "Trade", "message": { "itemId": "123", "price": "10000000000000000000" } })"); Passport->ZkEvmSignTypedDataV4(TypedDataJson, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnTypedDataSigned)); } private: void OnTypedDataSigned(FImmutablePassportResult Result) { if (!Result.Success) { UE_LOG(LogTemp, Error, TEXT("Sign Typed Data Failed: %s"), *Result.Error); return; } FString Signature = UImmutablePassport::GetResponseResultAsString(Result.Response); UE_LOG(LogTemp, Log, TEXT("Signature: %s"), *Signature); } // Call when user needs to sign typed data: SignTypedData(); ``` ## Provider Events The provider emits standard [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) events that you can subscribe to: ```typescript theme={null} provider.on('accountsChanged', (accounts: string[]) => { console.log('Accounts changed:', accounts); }); provider.on('chainChanged', (chainId: string) => { console.log('Chain changed:', chainId); }); provider.on('disconnect', (error: Error) => { console.log('Disconnected:', error); }); // Remove a specific listener provider.removeListener('accountsChanged', handler); ``` ## EIP-6963 Provider Discovery The wallet automatically announces itself via [EIP-6963](https://eips.ethereum.org/EIPS/eip-6963) for wallet discovery by dApps. Disable this if needed: ```typescript theme={null} const provider = await connectWallet({ announceProvider: false, }); ``` For manual announcement: ```typescript theme={null} import { announceProvider, passportProviderInfo } from '@imtbl/wallet'; announceProvider({ info: passportProviderInfo, provider: yourProvider, }); ``` ## Error Handling Use `WalletError` and `WalletErrorType` for typed error handling: ```typescript theme={null} import { connectWallet, WalletError, WalletErrorType } from '@imtbl/wallet'; try { const provider = await connectWallet(); const accounts = await provider.request({ method: 'eth_requestAccounts' }); } catch (error) { if (error instanceof WalletError) { switch (error.type) { case WalletErrorType.NOT_LOGGED_IN_ERROR: console.log('User is not logged in'); break; case WalletErrorType.WALLET_CONNECTION_ERROR: console.log('Failed to connect wallet:', error.message); break; case WalletErrorType.TRANSACTION_REJECTED: console.log('User rejected the transaction'); break; case WalletErrorType.UNAUTHORIZED: console.log('Unauthorized - call eth_requestAccounts first'); break; case WalletErrorType.GUARDIAN_ERROR: console.log('Guardian validation failed'); break; case WalletErrorType.INVALID_CONFIGURATION: console.log('Invalid wallet configuration'); break; case WalletErrorType.SERVICE_UNAVAILABLE_ERROR: console.log('Service temporarily unavailable'); break; default: console.error('Wallet error:', error.message); } } else { console.error('Unexpected error:', error); } } ``` ### Error Types | Error Type | Description | | --------------------------- | ------------------------------------- | | `NOT_LOGGED_IN_ERROR` | User is not authenticated | | `WALLET_CONNECTION_ERROR` | Failed to connect or link wallet | | `TRANSACTION_REJECTED` | User rejected a transaction | | `UNAUTHORIZED` | Operation requires authentication | | `GUARDIAN_ERROR` | Guardian (security) validation failed | | `INVALID_CONFIGURATION` | Invalid wallet configuration | | `SERVICE_UNAVAILABLE_ERROR` | Backend service unavailable | ### JSON-RPC Error Codes ```typescript theme={null} import { ProviderErrorCode, RpcErrorCode } from '@imtbl/wallet'; // Standard provider error codes (EIP-1193) ProviderErrorCode.USER_REJECTED_REQUEST // 4001 ProviderErrorCode.UNAUTHORIZED // 4100 ProviderErrorCode.UNSUPPORTED_METHOD // 4200 ProviderErrorCode.DISCONNECTED // 4900 // Standard RPC error codes RpcErrorCode.INVALID_REQUEST // -32600 RpcErrorCode.METHOD_NOT_FOUND // -32601 RpcErrorCode.INVALID_PARAMS // -32602 RpcErrorCode.INTERNAL_ERROR // -32603 ``` ```csharp theme={null} try { var response = await passport.ZkEvmSendTransactionWithConfirmation(request); Debug.Log($"Success: {response.transactionHash}"); } catch (Exception e) { Debug.LogError($"Transaction failed: {e.Message}"); } ``` ```cpp theme={null} // Add to your subsystem: public: void SendTransactionWithErrorHandling(const FString& ToAddress, const FString& Data) { auto Passport = GetPassport(); if (!Passport) { UE_LOG(LogTemp, Error, TEXT("Failed to get Passport instance")); return; } FImtblTransactionRequest Request; Request.to = ToAddress; Request.data = Data; Request.value = TEXT("0"); Passport->ZkEvmSendTransactionWithConfirmation(Request, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnTransactionComplete)); } private: void OnTransactionComplete(FImmutablePassportResult Result) { if (!Result.Success) { // Handle different error cases if (Result.Error.Contains(TEXT("User rejected"))) { UE_LOG(LogTemp, Warning, TEXT("User rejected the transaction")); } else if (Result.Error.Contains(TEXT("insufficient funds"))) { UE_LOG(LogTemp, Error, TEXT("Insufficient balance")); } else { UE_LOG(LogTemp, Error, TEXT("Transaction error: %s"), *Result.Error); } return; } TOptional Receipt = JsonObjectToUStruct(Result.Response.JsonObject); if (!Receipt.IsSet()) { UE_LOG(LogTemp, Error, TEXT("Failed to parse transaction receipt")); return; } UE_LOG(LogTemp, Log, TEXT("Transaction Hash: %s"), *Receipt->hash); UE_LOG(LogTemp, Log, TEXT("Status: %s"), *Receipt->status); } ``` ## Next Steps Gas sponsorship and transaction costs Instant transactions without popups (Unity/Unreal) Where users manage their wallet Understand the security model Mint NFTs to user wallets Contract allowlisting and verification # Play Source: https://docs.immutable.com/docs/products/play/overview Game discovery and wallet management for players Discover games, complete quests, and manage your wallet Immutable Play is the player-facing portal where users discover games, complete quests, and manage their Passport wallet. Direct your players here to manage their assets and engage with the broader Immutable ecosystem. ## Features Players browse trending and new games built on Immutable. Games with active quests appear prominently, driving organic discovery for your title. Players complete quests across multiple games in one place. Quests you create through [Questing](/docs/products/play/questing) appear here for players to discover and complete. The primary interface for players to view token balances, transaction history, and manage their Passport wallet. Players view their NFT collections, browse assets, and trade on the marketplace—all without leaving the platform. ## Wallet The **Wallet** tab is the primary interface for Passport wallet management. Players can: * View balances across tokens (ETH, IMX, and game tokens) * Send and receive tokens * Review transaction history * Fund their wallet When building wallet-related features in your game, consider linking to [play.immutable.com](https://play.immutable.com) for advanced wallet management rather than rebuilding these features yourself. ## Inventory The **Inventory** tab displays all NFT collections owned by the player. Features include: * Browse collections by game * View individual asset details and metadata * List assets for sale on the marketplace * Transfer assets to other wallets ## Quests & Discovery When you create quests using [Questing](/docs/products/play/questing), they become visible on Immutable Play. This provides: * **Organic discovery** — Players browsing Play find your game through active quests * **Centralised tracking** — Players track quest progress across all their games * **Ecosystem engagement** — Your game benefits from cross-promotion with other titles ## For Developers Immutable Play is player-facing, but understanding it helps you build better experiences: | Instead of... | Direct players to Play | | ----------------------------------- | -------------------------------------- | | Building a full wallet UI | Link to Play for balance/history views | | Creating an NFT browser | Link to Play for collection management | | Building a marketplace from scratch | Use Play's trading features | Your game still handles authentication, transactions, and gameplay. Play complements your game by providing wallet and asset management players expect. ## Next Steps Create quests that appear on Play Integrate authentication in your game # Questing Source: https://docs.immutable.com/docs/products/play/questing Keep players engaged with in-game and pre-launch quest systems Quests keep players engaged throughout your game's lifecycle. Players complete objectives, earn rewards, and climb leaderboards—whether your game is pre-launch or live. ## Overview The Audience Builder Program includes in-game quests hosted on your game's Audience Builder page in [Immutable Play](https://play.immutable.com). Players sign up using [Immutable Passport](/docs/products/passport/overview) (simple social sign-on) to: * Access the Immutable Play platform, game quests, and leaderboard competitions * Track questing and leaderboard progress * Receive rewards * Link social accounts to their Immutable Play account **No Passport integration required in-game.** Your game doesn't need to implement Immutable Passport as its login/wallet system or integrate any Immutable chain infrastructure. Simply track player progress using your existing backend and notify Immutable when a quest is completed via a lightweight API call. ## Responsibilities | Task | Owner | Description | | -------------------------------- | ------------------ | -------------------------------------------------------------------------- | | **Campaign & quest design** | Studio + Immutable | Collaborate to define quest structure, reward types, and timing | | **Player onboarding & linking** | Immutable | Players link an identifier matched to your backend during onboarding | | **Game login & player tracking** | Studio | Use your existing login and backend systems | | **Quest completion submission** | Studio | Track quest progress in-game and send completions to Immutable's Quest API | | **Reward matching & delivery** | Immutable | Match identifier to Passport and deliver rewards | ## How to Integrate Work with Immutable to agree on quests (e.g., "Play 30 arena matches") and their rewards Create an API key on [Immutable Hub](/docs/guides/advanced-setup/api-keys) and share your Org ID Use your existing backend to monitor when players meet quest criteria When a quest is completed, send a POST request to the Immutable Quest API with the player's linked identifier Immutable handles player matching and instantly updates their Play inventory ## Supported Identifiers Players can link one of these accounts to their Passport. Use this identifier when submitting quest completions. | Account Type | Account ID | Example | | ------------ | ----------------------- | -------------------------------------------- | | `telegram` | Telegram user ID | `1191097385` | | `discord` | Discord user ID | `1374611107196440770` | | `email` | Email address | `user@example.com` | | `metamask` | MetaMask wallet address | `0xc257274276a4e539741ca11b590b9447b26a8051` | | `passport` | Passport wallet address | `0xa9361c0dd68e1c3a69b43f646133fdab8d3859a2` | | `epic_games` | Epic Games user ID | `a26eca91eca340a7a8cadb886e7c1190` | **Notes on account types:** * `email` is not supported in Sandbox. Use another account type for testing. * `epic_games` account linking is not yet supported in Immutable Play. However, quest completions with `epic_games` will be stored and credited once linking is available—no progress will be lost. *** ## Quest Types Track gameplay actions: matches played, levels completed, items collected Follow on Twitter, join Discord, share content, engage with posts Invite friends, reach referral milestones, build your network Verify blockchain actions: first trade, first mint, collection completion ## Leaderboards Gamify your community with competitive rankings: | Leaderboard | Criteria | Rewards | | ------------ | ------------------- | --------------------------------- | | **All-Time** | Total points earned | Founder badges, exclusive items | | **Weekly** | Points this week | Featured spotlight, bonus rewards | | **Referral** | Friends referred | VIP access, revenue share | Players can spend gems to showcase their username on leaderboards across Immutable Play. *** ## Questing API The Questing API provides endpoints for recording quest completions, retrieving active quests, and fetching player earnings. Use these endpoints to integrate quest functionality into your game backend. ### Prerequisites * **Immutable API Key**: [Create one in Immutable Hub](/docs/guides/advanced-setup/api-keys) * **Game ID**: Provided during onboarding ### Required Quest: Login and Play Game As part of the Quest API integration, partners are required to implement the **Login and Play Game** quest, identified by the `login-play-game` `external_id`. This quest is configured as a **daily recurring quest** and should be triggered every time a player logs in. While triggered on login, it tracks participation on a daily basis, providing valuable insights into player engagement and recurring behavior patterns. ```json theme={null} { "external_id": "login-play-game", "account_type": "discord", "account_id": "901290221440733204" } ``` ### Important Notes **Quest completions are stored for non-Passport users.** If the identified user is not yet a registered Immutable Passport user, the quest completion will be stored with a reference to the account type and ID supplied in the request. When the user registers with Immutable Passport and links that account, they will be instantly rewarded and credited for this quest (and any other stored completions). This means user accounts do not need to be current Passport users when making requests. **Always use the `reference` field for idempotency.** We highly recommend using a unique `reference` field (e.g., `{accountId}-{externalId}-{timestamp}`) to ensure the same user cannot complete the same quest twice. If you receive a `409 Conflict` response, the quest completion was already recorded—don't retry. ### API Endpoints The Questing API provides three main endpoints: * **Quest Completions** — Record quest completions and trigger rewards (requires authentication) * **Get Live Quests** — Retrieve all currently active quests for your game (no authentication required) * **Latest Earnings** — Fetch player gem earning history by wallet address (no authentication required) Complete API documentation for all endpoints, including request/response schemas, authentication details, and code examples, is available in the API Reference tab. *** ## Best Practices Follow these best practices to ensure reliable quest completion tracking and optimal integration with the Quest API. ### Idempotency Always use the `reference` field to prevent duplicate quest completions. **Recommended Pattern** Use a unique reference string that combines player identifier, quest ID, and timestamp: ```typescript theme={null} const reference = `${accountId}-${externalId}-${Date.now()}`; ``` **Why It Matters** * Prevents accidental duplicate submissions * Enables safe retries on network failures * Provides audit trail for debugging If you receive a `409 Conflict` response, the quest completion was already recorded. Don't retry the request—treat it as success. ### Error Handling Implement robust error handling with appropriate retry logic. **Retry Strategy** ```typescript theme={null} async function submitQuestCompletionWithRetry( gameId: string, apiKey: string, externalId: string, accountType: string, accountId: string, reference: string, maxRetries = 3 ) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetch( `https://api.immutable.com/quests/v2/games/${gameId}/quest-completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-immutable-api-key': apiKey, }, body: JSON.stringify({ external_id: externalId, account_type: accountType, account_id: accountId, reference, completion_time: new Date().toISOString(), }), } ); // Success - return immediately if (response.ok) { return await response.json(); } // 409 Conflict - already recorded, treat as success if (response.status === 409) { console.log(`Quest completion already recorded: ${reference}`); return { result: { status: 'already_completed' } }; } // 4xx errors - don't retry (client error) if (response.status >= 400 && response.status < 500) { const error = await response.json(); throw new Error(`Client error: ${error.message || response.statusText}`); } // 5xx errors - retry with exponential backoff if (response.status >= 500) { if (attempt < maxRetries) { const delay = Math.pow(2, attempt) * 1000; // Exponential backoff await new Promise(resolve => setTimeout(resolve, delay)); continue; } throw new Error(`Server error after ${maxRetries} attempts`); } } catch (error) { if (attempt === maxRetries) { throw error; } // Network errors - retry with exponential backoff const delay = Math.pow(2, attempt) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); } } } ``` **Error Response Handling** | Status Code | Action | | -------------------------- | ------------------------------------------------ | | `200` | Success - quest completion recorded | | `409` | Already recorded - treat as success, don't retry | | `400`, `401`, `403`, `404` | Client error - don't retry, log and investigate | | `500`, `504` | Server error - retry with exponential backoff | ### Logging Always log quest completion requests for debugging and auditing. **What to Log** ```typescript theme={null} function logQuestCompletion( externalId: string, accountType: string, accountId: string, reference: string, status: 'success' | 'error', error?: string ) { const logEntry = { timestamp: new Date().toISOString(), quest: externalId, account: { type: accountType, id: accountId }, reference, status, error, }; // Log to your preferred logging service console.log(JSON.stringify(logEntry)); // Optionally send to monitoring service // sendToMonitoring(logEntry); } ``` **Logging Best Practices** * Log before making the API request * Log the response status and any errors * Include the reference ID for correlation * Don't log sensitive data (mask account IDs if needed) * Use structured logging for easier querying ### Rate Limiting The Quest API implements rate limiting to ensure fair usage. **Rate Limit Tiers** | Tier | Rate Limit | | -------------- | ---------- | | **Standard** | 50 req/sec | | **Enterprise** | Custom | **Default rate limits:** * Global default: 5 requests per second per public IP * Partner adjusted rate limit: 50 requests per second per API key * Enterprise: Custom rate limits based on your agreement **Recommendations** * **Batch quest completions** when possible (if multiple quests complete simultaneously) * **Implement client-side rate limiting** to avoid hitting limits * **Use exponential backoff** when receiving rate limit errors (typically `429` status) * **Monitor your API usage** in [Immutable Hub](https://hub.immutable.com) — see [API Keys](/docs/guides/advanced-setup/api-keys) for details **Handling Rate Limits** ```typescript theme={null} if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); const delay = retryAfter ? parseInt(retryAfter) * 1000 : 60000; // Default 60s await new Promise(resolve => setTimeout(resolve, delay)); // Retry the request } ``` ### Account ID Validation Validate account IDs before submitting to ensure correct format. **Validation Examples** ```typescript theme={null} function validateAccountId(accountType: string, accountId: string): boolean { switch (accountType) { case 'discord': // Discord IDs are numeric strings (snowflakes) return /^\d+$/.test(accountId); case 'telegram': // Telegram IDs are numeric return /^\d+$/.test(accountId); case 'metamask': case 'passport': // Wallet addresses are hex strings starting with 0x return /^0x[a-fA-F0-9]{40}$/.test(accountId); case 'email': // Basic email validation return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(accountId); case 'epic_games': // Epic Games IDs are alphanumeric strings return /^[a-zA-Z0-9]+$/.test(accountId); default: return false; } } ``` ### Async Processing Consider processing quest completions asynchronously to avoid blocking game logic. **Queue-Based Approach** ```typescript theme={null} // Add to queue instead of blocking questCompletionQueue.add({ externalId: 'catch-3-fish', accountType: 'discord', accountId: playerDiscordId, reference: generateReference(), }); // Process queue in background async function processQuestQueue() { while (true) { const item = await questCompletionQueue.get(); try { await submitQuestCompletion(item); } catch (error) { // Log error and potentially re-queue logError(error); } } } ``` ### Testing Test your integration thoroughly before going live. **Test Scenarios** 1. **Successful completion** - Verify quest is recorded correctly 2. **Duplicate submission** - Ensure idempotency works with same reference 3. **Invalid account ID** - Verify proper error handling 4. **Network failures** - Test retry logic 5. **Rate limiting** - Verify backoff behavior 6. **Required quest** - Ensure `login-play-game` is triggered on login **Sandbox Testing** * Use Sandbox environment for all testing * Test with `discord`, `telegram`, or `metamask` account types (not `email`) * Verify quest completions appear in Immutable Play (Sandbox) ### Monitoring Set up monitoring and alerts for quest completion failures. **Key Metrics to Track** * Quest completion success rate * API error rates by status code * Average response time * Rate limit hits * Duplicate completion attempts **Alert Thresholds** * Error rate > 5% * Response time > 2 seconds * Rate limit errors detected *** ## FAQ No. The Quest API is designed to work with your existing authentication system. Players link their accounts (Discord, Telegram, email, etc.) to [Immutable Passport](/docs/products/passport/overview) through Immutable Play, but your game doesn't need to implement Passport authentication. Quest completions are stored retroactively. When a player registers with [Immutable Passport](/docs/products/passport/overview) and links their account (Discord, Telegram, etc.), they'll automatically receive credit for all previously completed quests. Quest completions are stored with the account type and ID you provide—when the player eventually links that account to Passport, they'll receive all stored rewards. Yes. Quest completions are stored with the account type and ID you provide. When the player eventually links that account to [Immutable Passport](/docs/products/passport/overview), they'll receive all stored rewards. The `login-play-game` quest is a required daily recurring quest that tracks player engagement. You must trigger this quest every time a player logs into your game. It provides valuable data on daily active users and recurring behavior patterns. Always include a unique `reference` field in your quest completion requests. Use a pattern like `{accountId}-{externalId}-{timestamp}`. If you receive a `409 Conflict` response, the quest was already recorded—don't retry. Supported account types are: `telegram`, `discord`, `email`, `metamask`, `passport`, and `epic_games`. Note that `email` is not supported in Sandbox—use `discord`, `telegram`, or `metamask` for testing. **Important:** `metamask` and other third-party wallets need to be linked by the user either through the Play profile settings or a custom UX built by the studio using the [link wallet API/SDK functionality](/docs/products/passport/wallet#linked-addresses). Your Game ID is provided by Immutable's Growth team during onboarding. Contact them if you don't have it yet. A `409 Conflict` means the quest completion was already recorded (likely due to a duplicate `reference`). Treat this as success—don't retry the request. * **4xx errors (400, 401, 403)**: Don't retry—these indicate client errors that need investigation * **404 Not Found**: Don't retry—see common causes below * **409 Conflict**: Treat as success—quest already recorded * **429 Rate Limit**: Wait for the `Retry-After` header duration, then retry * **5xx errors (500, 504)**: Retry with exponential backoff A `404 Not Found` error can occur when: * The quest doesn't exist (check the `external_id` is correct) * The quest has expired (check `valid_from` and `valid_to` dates) * The request was made to a production endpoint with a sandbox quest ID (or vice versa) Verify you're using the correct environment (Sandbox vs Production) and that the quest is active. If the issue persists, contact your Growth team. The Quest API accepts one quest completion per request. If multiple quests complete simultaneously, submit them sequentially. Consider using a queue system to handle high-volume scenarios. Use the Sandbox environment (`https://api.sandbox.immutable.com`) for testing. Test with `discord`, `telegram`, or `metamask` account types. Verify quest completions appear in Immutable Play (Sandbox). Additionally, studios can: * Visit [play.sandbox.immutable.com](https://play.sandbox.immutable.com) to see quest and reward status * Use the Live Quests endpoint (`GET /v1/quests/games/{game_id}/quests`) to see the number of quest completions The `completion_time` field allows you to specify when the quest was actually completed (in RFC3339 UTC format). This is useful for retroactive attribution or batch processing. If omitted, the API uses the current time. Rewards are typically delivered instantly when a quest completion is successfully recorded. If a player hasn't linked their account yet, rewards are stored and delivered when they link their account to Passport. Quest completion data is visible in Immutable Play. Players can see their quest progress and rewards in their Play inventory. For analytics, you can view quest completion data via the [Immutable Hub Insights feature](https://hub.immutable.com). The Insights feature is enabled with an Immutable Audience Builder subscription. You can also contact Immutable's Growth team for additional analytics. Quest definitions are managed in collaboration with Immutable's Growth team. Contact them to discuss changes to quest structure, rewards, or timing. Yes, the Quest API implements rate limiting to ensure fair usage: * **Standard tier**: 50 requests per second per API key * **Global default**: 5 requests per second per public IP * **Enterprise**: Custom rate limits based on your agreement If you receive a `429 Too Many Requests` response, implement exponential backoff and retry after the duration specified in the `Retry-After` header. **Common causes of 429 errors:** * The API key is not being used correctly in the request flow * Sub-optimal integration where too many requests are made than needed * Increase in base traffic (which is a great sign for the project) If you experience persistent 429 errors, contact your Growth team to review your integration and discuss potential optimizations or rate limit adjustments. Yes, if your API key has the necessary permissions. However, ensure your key has access to the Quest API endpoints for your game. Contact Immutable support through [support.immutable.com](https://support.immutable.com) or reach out to your Growth team contact for Quest API-specific questions. *** Create your API key in Immutable Hub Get help setting up your quest campaign ## Next Steps Learn about the full Audience suite Set up your pre-launch landing page Configure quest completion campaigns Track quest performance data # Weekly Rewards Source: https://docs.immutable.com/docs/products/play/weekly-rewards Earn IMX tokens, bonus Gems, and exclusive prizes every week The Weekly Rewards program on Immutable Play gives you the chance to earn IMX tokens, bonus Gems, and exclusive prizes every week. By completing quests and engaging with games, you can participate in Weekly Draws and compete for rewards. The Weekly Rewards program is sponsored by the IMX Ecosystem Foundation and operated by Immutable Play. ## How it works The Weekly Rewards program is built around a weekly cycle: Complete quests, follow games, and claim daily rewards to collect Gems Exchange 500 Gems for a Key that enters you into the Weekly Draw Complete eligible quests to earn Weekly Points and upgrade your Key tier Receive IMX tokens, bonus Gems, or free entries when the draw concludes ## Key concepts | Concept | Description | | ------------------ | ----------------------------------------------------------------------- | | **Gems** | The currency you earn through gameplay activities on Immutable Play | | **Keys** | Entry tickets for the Weekly Draw, obtained by redeeming 500 Gems | | **Key Tiers** | Five levels (Common to Legendary) that determine your prize pool access | | **Weekly Draw** | The weekly event where rewards are distributed to participants | | **Mythic Rewards** | Top-tier IMX prizes awarded to select participants each week | ## Eligibility requirements To participate in the Weekly Rewards program, you need: * **Immutable Passport wallet** - The program is only available to Passport users * **Age requirement** - You must be 18 years or older * **Regional availability** - The program is available worldwide, except where prohibited by law ## Getting started Ready to start earning rewards? Follow these steps: Visit [play.immutable.com](https://play.immutable.com) with your Passport account Complete quests and daily activities to collect Gems Redeem 500 Gems for a Key Increase your Key tier by earning Weekly Points Collect your prizes when the draw concludes # Game Bridge Architecture Source: https://docs.immutable.com/docs/sdks/game-bridge-architecture Learn how Immutable game SDKs work internally and how to adapt them for custom engines. This page is primarily for developers building custom engine integrations or wanting to understand the SDK architecture. If you're using Unity or Unreal, refer to their respective SDK documentation. ## Overview The Unity and Unreal SDKs share a common architecture that bridges native game engines with TypeScript-based functionality. This design allows consistent behavior across platforms while leveraging platform-specific capabilities. The architecture has three main components: a **game bridge** for communication, an **invisible WebView** for executing TypeScript, and an **in-app browser** for secure authentication. ## Architecture Diagrams ### High-Level Architecture ```mermaid theme={null} graph TB Game["Game"] subgraph GameSDK["Game SDK"] direction TB subgraph InvisibleWebView["Invisible WebView"] direction TB subgraph IndexFile["index file"] direction TB GameBridge["game-bridge"] RequiredSDK["Required Immutable
TypeScript SDK packages"] end end end Game --> GameSDK ``` ### Communication Flow How the game engine communicates with TypeScript SDK packages through JSON serialization: ```mermaid theme={null} graph LR Game["Game Engine"] SDK["Game SDK"] WebView["Invisible
WebView"] IndexFile["Game Bridge"] TSPackage["TypeScript
SDK"] Game <-->|"function call"| SDK SDK <-->|"JSON serialize/
deserialize"| WebView WebView <-->|"callFunction()
parse JSON"| IndexFile IndexFile <-->|"callFunction()
callbackToGame()"| TSPackage ``` ### PKCE Authentication Flow How authentication works using in-app browsers for security: ```mermaid theme={null} sequenceDiagram participant Game participant SDK as Game SDK participant Native as Native Code participant Browser as In-App Browser Game->>SDK: Login via PKCE flow SDK->>Native: Launch in-app browser Native->>Browser: Open authentication URL Browser->>Browser: User authenticates Browser->>Native: Deep link callback Native->>SDK: Authentication complete SDK->>Game: Login success ``` ## Core Components ### 1. Game Bridge The game bridge enables string-based communication between your game engine and the [TypeScript SDK](/docs/sdks/typescript). **How it works:** * The [`game-bridge`](https://github.com/immutable/ts-immutable-sdk/tree/main/packages/game-bridge) package enables communication with TypeScript SDK packages * TypeScript packages are bundled into a single file loaded into an invisible WebView * The game SDK sends JSON string messages to the WebView and receives responses **Implementation:** * Unity: [`index.html`](https://github.com/immutable/unity-immutable-sdk/blob/main/src/Packages/Passport/Runtime/Resources/index.html) * Unreal: [`index.js`](https://github.com/immutable/unreal-immutable-sdk/blob/main/Web/index.js) Both contain the bundled TypeScript SDK and `game-bridge` package. ### 2. WebView An invisible WebView loads the game bridge and executes TypeScript SDK functionality. **Requirements:** * HTML/JavaScript loading support * Modern JavaScript (ES6+) * Bidirectional native-JavaScript communication * Hidden from user view (no UI rendering) **Platform considerations:** * Mobile: Native WebView components (Android/iOS) * Desktop: May require embedded browser frameworks * Windows: CEF 90+ required ### 3. In-App Browser Authentication uses an in-app browser (not WebView) for security and SSO compatibility. **Requirements:** * Secure, isolated browser context * Process isolation (runs separately from your app) * Deep link callback handling * System authentication session support Never use WebView for authentication. In-app browsers run in separate processes, preventing credential theft and meeting OAuth 2.0 best practices. See [Why in-app browser?](/docs/sdks/unity/faq) for details. For platform-specific implementations, see: * [Unity SDK Repository](https://github.com/immutable/unity-immutable-sdk) * [Unreal SDK Repository](https://github.com/immutable/unreal-immutable-sdk) ## Adapting for Custom Engines This section is for custom game engine integrations. Unity and Unreal users don't need to modify these components. To adapt the SDK for your custom engine, you'll need to modify three areas: ### 1. Modify the Game Bridge Keep the bundled TypeScript SDK but modify the `callbackToGame()` function in [`game-bridge/index.ts`](https://github.com/immutable/ts-immutable-sdk/blob/main/packages/game-bridge/src/index.ts) to communicate with your engine. After modification, rebuild the bundle following the [game-bridge README](https://github.com/immutable/ts-immutable-sdk/blob/main/packages/game-bridge/README.md). ### 2. Implement WebView Communication **Option A:** Reuse our platform-native WebView implementations and adapt the communication layer **Option B:** Implement a custom WebView with bidirectional string communication and lifecycle management **Reference implementation:** * C# engines: [Unity SDK](https://github.com/immutable/unity-immutable-sdk) * C++ engines: [Unreal SDK](https://github.com/immutable/unreal-immutable-sdk) ### 3. Implement PKCE Authentication Reuse our platform-native PKCE implementations and deep linking handlers. Adapt the communication layer between your engine and native code for URL scheme registration and callback handling. The Game SDK is lightweight—essentially a wrapper around the TypeScript SDK. Most complexity is in the bridge and WebView communication, not the SDK logic itself. ## Related Documentation Unity SDK implementation Unreal SDK implementation Core TypeScript packages Game bridge source code Common questions about SDK architecture # FAQ Source: https://docs.immutable.com/docs/sdks/typescript/faq # Frequently Asked Questions Common questions and solutions for build issues, authentication, transactions, and debugging with the TypeScript SDK. ## Build & Environment Modern bundlers don't include Node.js polyfills by default. **Vite:** ```bash theme={null} npm install vite-plugin-node-polyfills ``` ```typescript theme={null} // vite.config.ts import { nodePolyfills } from 'vite-plugin-node-polyfills'; export default defineConfig({ plugins: [ nodePolyfills({ include: ['buffer', 'crypto', 'stream', 'util'], }), ], }); ``` **Next.js / Create React App:** ```typescript theme={null} // Add to top of your entry file import { Buffer } from 'buffer'; if (typeof window !== 'undefined') { window.Buffer = Buffer; } ``` Add global polyfill to your bundler config: ```typescript theme={null} // vite.config.ts export default defineConfig({ define: { global: 'globalThis', }, }); ``` Ensure `tsconfig.json` uses modern module resolution: ```json theme={null} { "compilerOptions": { "moduleResolution": "bundler", // or "node16" "esModuleInterop": true } } ``` Import from modular packages instead of the umbrella SDK: ```typescript theme={null} // ❌ May cause issues with App Router import { config, auth } from '@imtbl/sdk'; // ✅ Works correctly - use modular packages import { Auth } from '@imtbl/auth'; import { connectWallet } from '@imtbl/wallet'; import { Environment } from '@imtbl/config'; ``` If you see elliptic package errors, add to `next.config.js`: ```javascript theme={null} const nextConfig = { experimental: { esmExternals: false, }, }; ``` Import modular packages instead of the umbrella package: ```typescript theme={null} // ❌ Imports everything import { auth, orderbook } from '@imtbl/sdk'; // ✅ Imports only what you need import { Auth } from '@imtbl/auth'; import { Orderbook } from '@imtbl/orderbook'; ``` Using modular packages can significantly reduce your bundle size by importing only what you need. Clear your cache and reinstall dependencies: ```bash theme={null} # Remove node_modules and package lock rm -rf node_modules package-lock.json # Clear npm cache npm cache clean --force # Reinstall dependencies npm install ``` **For Vite projects:** ```bash theme={null} rm -rf node_modules .vite package-lock.json npm cache clean --force npm install ``` **For Next.js projects:** ```bash theme={null} rm -rf node_modules .next package-lock.json npm cache clean --force npm install ``` For framework-specific bundler configuration, see the [Framework Setup](/docs/sdks/typescript/overview#framework-setup) tabs in the overview. ## Regional Support The Immutable Game SDK supports only the regions that Immutable Passport supports. Passport is a globally available product. However, our wallet infrastructure is subject to the regulation of the US Department of the Treasury's Office of Foreign Assets Control ("OFAC"). OFAC administers and enforces comprehensive and targeted economic and trade sanctions programs on multiple countries and regions. Users attempting to access Passport in any of the regions under OFAC sanction will have their access blocked and will be unable to use our product. Additionally, components of Passport's infrastructure also rely on technology provided by [Magic](https://magic.link/), which maintains further details regarding unsupported regions on their website [here](https://help.magic.link/knowledge/prohibited-regions). ## Authentication The `redirectUri` in your code must **exactly** match what's configured in [Hub](https://hub.immutable.com). Common mismatches: * `http` vs `https` * Trailing slash (`/callback` vs `/callback/`) * Port number differences * Different paths ```typescript theme={null} // ❌ Wrong - trailing slash mismatch redirectUri: 'http://localhost:3000/callback/' // ✅ Correct - matches Hub config exactly redirectUri: 'http://localhost:3000/callback' ``` The redirect URI must match **exactly** including trailing slashes and port numbers. Verify the following: 1. `clientId` matches your Hub configuration 2. You're using the correct environment (Sandbox vs Production) 3. `publishableKey` is set correctly Browsers block popups that aren't triggered by user interaction. ```typescript theme={null} // ❌ Wrong - called on page load useEffect(() => { auth.login(); // Blocked! }, []); // ✅ Correct - triggered by user click ``` Always trigger authentication flows from user interactions (button clicks) to avoid popup blockers. Ensure `loginCallback()` is called on your redirect URI page: ```typescript theme={null} // app/callback/page.tsx useEffect(() => { auth.loginCallback() .then(() => router.push('/')) .catch(console.error); }, []); ``` ## Transactions User cancelled the transaction in the Passport popup. Handle this gracefully: ```typescript theme={null} try { await walletClient.sendTransaction({ to, value }); } catch (error) { if (error.name === 'UserRejectedRequestError') { // User cancelled - show friendly message return; } throw error; } ``` The user doesn't have enough IMX for the transaction + gas fees. ```typescript theme={null} import { formatEther } from 'viem'; const balance = await publicClient.getBalance({ address }); console.log('Balance:', formatEther(balance), 'IMX'); ``` Direct users to fund their wallet via the [Checkout widgets](/docs/products/checkout/fund). Usually caused by pending transactions. Wait for pending transactions to confirm: ```typescript theme={null} const pendingNonce = await publicClient.getTransactionCount({ address, blockTag: 'pending', }); ``` ## Performance & Debugging Initialize once and reuse the instance: ```typescript theme={null} // ❌ Wrong - creates new instance every call function getAuth() { return new Auth({ ... }); } // ✅ Correct - singleton pattern let auth: Auth | null = null; function getAuth() { if (!auth) { auth = new Auth({ ... }); } return auth; } ``` Use the singleton pattern to initialize SDK instances once and reuse them throughout your application. Enable debug logs in the browser console: ```typescript theme={null} // In browser console localStorage.setItem('debug', 'imtbl:*'); ``` Then refresh the page to see SDK debug output. SDK calls go to: * **Sandbox**: `api.sandbox.immutable.com` * **Production**: `api.immutable.com` Check the browser Network tab for failed requests and error responses. | Code | Meaning | Solution | | ---- | ------------ | ------------------------------------------------- | | 401 | Unauthorized | Check API key or access token | | 403 | Forbidden | Check permissions in Hub | | 429 | Rate limited | Implement exponential backoff | | 500 | Server error | Retry with backoff, contact support if persistent | ## Integration & Usage viem has built-in Immutable Chain support: ```typescript theme={null} import { createWalletClient, createPublicClient, custom, http } from 'viem'; import { immutableZkEvm, immutableZkEvmTestnet } from 'viem/chains'; import { connectWallet } from '@imtbl/wallet'; const provider = await connectWallet({ auth }); const walletClient = createWalletClient({ chain: immutableZkEvmTestnet, transport: custom(provider), }); const publicClient = createPublicClient({ chain: immutableZkEvmTestnet, transport: http(), }); const [address] = await walletClient.getAddresses(); const hash = await walletClient.sendTransaction({ to: '0x...', value: parseEther('0.1'), }); ``` Use `@imtbl/webhook` to verify incoming webhooks from Immutable: ```typescript theme={null} import { validateWebhook } from '@imtbl/webhook'; app.post('/webhook', (req, res) => { const signature = req.headers['x-immutable-signature'] as string; const rawBody = req.body; // Must be raw body, not parsed JSON const isValid = validateWebhook( rawBody, signature, process.env.WEBHOOK_SECRET! ); if (!isValid) { return res.status(403).send('Invalid signature'); } res.status(200).send('OK'); }); ``` For browser-only applications without a build step: ```html theme={null} ``` CDN deployment is useful for quick prototypes and static sites. For production applications, use npm packages with proper bundling. Requires TypeScript 5.0+ with `moduleResolution: "bundler"` in `tsconfig.json`. ```typescript theme={null} // ✅ Correct - imports only the module you need import { Passport } from '@imtbl/sdk/passport'; import { Orderbook } from '@imtbl/sdk/orderbook'; import { BlockchainData } from '@imtbl/sdk/blockchain-data'; // ❌ Avoid - imports entire SDK import { Passport, Orderbook } from '@imtbl/sdk'; ``` **Breaking Change**: V2 SDK uses **Ethers.js v6** (upgraded from v5). If your application uses Ethers.js, you must migrate. Key changes: * `WrappedBrowserProvider` for Checkout SDK * `passport.connectEvm()` is now **async** — must `await` before passing to Checkout * See [Ethers.js v6 migration guide](https://docs.ethers.org/v6/migrating/) for details *** ## Still Need Help? Complete TypeScript SDK documentation Report TypeScript SDK issues Contact player support Browse all documentation # TypeScript SDK Source: https://docs.immutable.com/docs/sdks/typescript/overview Typed clients for all Immutable APIs and services. **Who is this for?** Web developers and TypeScript developers looking to build web3 applications and integrate blockchain features seamlessly. ## Packages Install only what you need: | Package | Purpose | Install | | ------------------------- | -------------------------------------- | ------------------------------- | | `@imtbl/auth` | Authentication & sessions | `npm i @imtbl/auth` | | `@imtbl/wallet` | Embedded wallets & transactions | `npm i @imtbl/wallet` | | `@imtbl/auth-next-server` | Next.js server-side auth (Auth.js v5) | `npm i @imtbl/auth-next-server` | | `@imtbl/auth-next-client` | Next.js client-side hooks & components | `npm i @imtbl/auth-next-client` | | `@imtbl/orderbook` | NFT trading | `npm i @imtbl/orderbook` | | `@imtbl/blockchain-data` | On-chain data queries (Indexer) | `npm i @imtbl/blockchain-data` | | `@imtbl/minting-backend` | Server-side minting | `npm i @imtbl/minting-backend` | | `@imtbl/contracts` | Smart contract ABIs & types | `npm i @imtbl/contracts` | | `@imtbl/webhook` | Webhook signature validation | `npm i @imtbl/webhook` | | `@imtbl/config` | Environment configuration | `npm i @imtbl/config` | Install individual packages instead of `@imtbl/sdk` for smaller bundles and better tree-shaking. ## Installation ```bash theme={null} npm install @imtbl/sdk ``` ```bash theme={null} yarn add @imtbl/sdk ``` ```bash theme={null} pnpm add @imtbl/sdk ``` Or install individual packages for smaller bundles: ```bash theme={null} npm install @imtbl/auth @imtbl/wallet @imtbl/config ``` ## Framework Setup Use `@imtbl/auth-next-server` and `@imtbl/auth-next-client` for full Next.js integration with server-side session management, automatic token refresh, and route protection. These packages require Next.js 14+ with the App Router. For Pages Router or other React frameworks, use `@imtbl/auth` directly. ### Prerequisites * **Client ID** from your [Passport client in Hub](/docs/products/passport/clients) * Next.js 14 or 15 with App Router * An `AUTH_SECRET` environment variable (any random string, minimum 32 characters) ### Installation ```bash theme={null} npm install @imtbl/auth-next-server @imtbl/auth-next-client next-auth@5 ``` ### Setup Create your auth configuration: ```typescript theme={null} // lib/auth.ts import { NextAuth, createAuthConfig } from "@imtbl/auth-next-server"; export const { handlers, auth, signIn, signOut } = NextAuth({ ...createAuthConfig({ clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!, redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`, }), secret: process.env.AUTH_SECRET, trustHost: true, }); ``` Create the API route handler: ```typescript theme={null} // app/api/auth/[...nextauth]/route.ts import { handlers } from "@/lib/auth"; export const { GET, POST } = handlers; ``` Wrap your app with `SessionProvider`: ```tsx theme={null} // app/layout.tsx import { SessionProvider } from "next-auth/react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Set environment variables: ```env theme={null} # .env.local NEXT_PUBLIC_IMMUTABLE_CLIENT_ID=your_client_id NEXT_PUBLIC_BASE_URL=http://localhost:3000 AUTH_SECRET=your-secret-key-min-32-characters ``` Handle the OAuth redirect after login. ```tsx theme={null} // app/callback/page.tsx 'use client'; import { CallbackPage, type ImmutableAuthConfig } from '@imtbl/auth-next-client'; const config: ImmutableAuthConfig = { clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!, redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`, audience: 'platform_api', scope: 'openid profile email offline_access transact', }; export default function Callback() { return ( Completing authentication...

} /> ); } ```
```env theme={null} # .env.local NEXT_PUBLIC_IMMUTABLE_CLIENT_ID=your_client_id NEXT_PUBLIC_BASE_URL=http://localhost:3000 AUTH_SECRET=your-secret-key-min-32-characters ```
For login, logout, session management, and wallet operations using the client hooks, see the **Next.js** tabs on the [Authentication](/docs/products/passport/authentication) and [Wallet](/docs/products/passport/wallet) pages.
```bash theme={null} npm install @imtbl/auth @imtbl/wallet ``` ### Initialization ```typescript theme={null} // src/lib/immutable.ts import { Auth } from '@imtbl/auth'; import { connectWallet, ZkEvmProvider } from '@imtbl/wallet'; export const auth = new Auth({ clientId: import.meta.env.VITE_CLIENT_ID, redirectUri: `${window.location.origin}/callback`, logoutRedirectUri: window.location.origin, audience: 'platform_api', scope: 'openid offline_access email transact', }); let providerPromise: Promise | null = null; export function getProvider() { if (!providerPromise) { providerPromise = connectWallet({ auth }); } return providerPromise; } ``` ### Environment Variables ```env theme={null} VITE_CLIENT_ID=your_client_id ``` ### Vite Configuration (Required) Vite requires Node.js polyfills for the SDK. Without this configuration, builds will fail. ```bash theme={null} npm install --save-dev vite-plugin-node-polyfills ``` ```typescript theme={null} // vite.config.ts import { defineConfig } from 'vite'; import { nodePolyfills } from 'vite-plugin-node-polyfills'; export default defineConfig({ plugins: [ nodePolyfills() ] }); ``` Use Passport with wagmi for React hooks: ```bash theme={null} npm install wagmi @imtbl/auth @imtbl/wallet ``` ### Configuration ```typescript theme={null} import { createConfig, http } from 'wagmi'; import { immutableZkEvmTestnet } from 'wagmi/chains'; import { injected } from 'wagmi/connectors'; export const wagmiConfig = createConfig({ chains: [immutableZkEvmTestnet], connectors: [injected()], transports: { [immutableZkEvmTestnet.id]: http(), }, }); ``` ### Component Example ```tsx theme={null} import { useAccount, useConnect } from 'wagmi'; function WalletButton() { const { address, isConnected } = useAccount(); const { connect, connectors } = useConnect(); if (isConnected) { return {address?.slice(0, 8)}...; } return ( ); } ```
## Next.js Configuration ### `createAuthConfig` Creates an Auth.js v5 configuration object for Immutable authentication. | Option | Type | Required | Default | | ---------------------- | -------- | -------------------------- | ------------------------------------------------ | | `clientId` | `string` | Yes (when config provided) | Sandbox client ID | | `redirectUri` | `string` | Yes (when config provided) | `origin + '/callback'` | | `audience` | `string` | No | `"platform_api"` | | `scope` | `string` | No | `"openid profile email offline_access transact"` | | `authenticationDomain` | `string` | No | `"https://auth.immutable.com"` | When called with no arguments, `createAuthConfig()` uses sandbox defaults for quick prototyping. Zero-config mode uses a shared public sandbox client ID. For production, always use your own client ID from [Immutable Hub](https://hub.immutable.com). ### Extending the configuration You can spread the base config and add any Auth.js options. This is the pattern used in the Passport sample app to support multiple environments: ```typescript theme={null} import { NextAuth, createAuthConfig } from "@imtbl/auth-next-server"; export const { handlers, auth, signIn, signOut } = NextAuth({ ...createAuthConfig({ clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!, redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`, }), secret: process.env.AUTH_SECRET, trustHost: true, basePath: "/api/auth", }); ``` ## Next.js Server Utilities Use these in Server Components and Server Actions to handle authentication on the server. ### `getAuthProps` Pass authentication state to a Client Component when data fetching happens client-side. ```typescript theme={null} // app/dashboard/page.tsx import { auth } from "@/lib/auth"; import { getAuthProps } from "@imtbl/auth-next-server"; import { redirect } from "next/navigation"; import { DashboardClient } from "./DashboardClient"; export default async function DashboardPage() { const authProps = await getAuthProps(auth); if (authProps.authError) { redirect("/login"); } return ; } ``` ### `getAuthenticatedData` Fetch data server-side for faster initial loads, with automatic client-side fallback when the token is expired. ```typescript theme={null} // app/profile/page.tsx import { auth } from "@/lib/auth"; import { getAuthenticatedData } from "@imtbl/auth-next-server"; import { redirect } from "next/navigation"; async function fetchProfile(accessToken: string) { const res = await fetch("https://api.immutable.com/v1/user/profile", { headers: { Authorization: `Bearer ${accessToken}` }, }); return res.json(); } export default async function ProfilePage() { const result = await getAuthenticatedData(auth, fetchProfile); if (result.authError) redirect("/login"); return ; } ``` ### `createProtectedFetchers` Define auth error handling once and reuse it across all protected pages. ```typescript theme={null} // lib/protected.ts import { auth } from "@/lib/auth"; import { createProtectedFetchers } from "@imtbl/auth-next-server"; import { redirect } from "next/navigation"; export const { getAuthProps, getData } = createProtectedFetchers( auth, (error) => redirect(`/login?error=${error}`), ); ``` ```typescript theme={null} // app/settings/page.tsx import { getAuthProps } from "@/lib/protected"; export default async function SettingsPage() { const authProps = await getAuthProps(); return ; } ``` ### `getValidSession` Get fine-grained control over different authentication states. ```typescript theme={null} import { auth } from "@/lib/auth"; import { getValidSession } from "@imtbl/auth-next-server"; export default async function AccountPage() { const result = await getValidSession(auth); switch (result.status) { case "authenticated": return ; case "token_expired": return ; case "unauthenticated": return ; case "error": return ; } } ``` ## Next.js Route Protection ### Middleware Protect entire sections of your app at the routing level before pages render. Use middleware when you have groups of pages that all require authentication (such as `/dashboard/*` or `/settings/*`) and you want to redirect unauthenticated users before any page code runs. Do not use middleware for pages that show different content for authenticated vs unauthenticated users, or for public pages with optional authenticated features. Use page-level checks with `getAuthProps` or `getValidSession` instead. ```typescript theme={null} // middleware.ts import { createAuthMiddleware } from "@imtbl/auth-next-server"; import { auth } from "@/lib/auth"; export default createAuthMiddleware(auth, { loginUrl: "/login", publicPaths: ["/", "/about", "/api/public"], }); export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], }; ``` | Option | Type | Description | | ---------------- | ---------------------- | --------------------------------------------------------------- | | `loginUrl` | `string` | Redirect target for unauthenticated users (default: `"/login"`) | | `protectedPaths` | `(string \| RegExp)[]` | Paths that require authentication | | `publicPaths` | `(string \| RegExp)[]` | Paths that skip authentication (takes precedence) | ### Protected API Routes Use `withAuth` to protect individual Route Handlers or Server Actions. ```typescript theme={null} // app/api/user/inventory/route.ts import { auth } from "@/lib/auth"; import { withAuth } from "@imtbl/auth-next-server"; import { NextResponse } from "next/server"; export const GET = withAuth(auth, async (session, request) => { const inventory = await fetchUserInventory(session.accessToken); return NextResponse.json(inventory); }); ``` ```typescript theme={null} // app/actions/transfer.ts "use server"; import { auth } from "@/lib/auth"; import { withAuth } from "@imtbl/auth-next-server"; export const transferAsset = withAuth(auth, async (session, formData: FormData) => { const assetId = formData.get("assetId") as string; const toAddress = formData.get("toAddress") as string; return await executeTransfer({ from: session.user.sub, to: toAddress, assetId, accessToken: session.accessToken, }); }); ``` Inside `withAuth` handlers, the `session` object includes `accessToken` directly since these run server-side. This is different from the client-side `useImmutableSession` hook where you must use `getAccessToken()`. ## Next.js Session Type Reference The server-side session (available in `withAuth` handlers and `getAuthenticatedData` fetchers) includes the following fields: | Field | Type | Description | | ------------------------ | --------- | ----------------------------------------- | | `user.sub` | `string` | Immutable user ID | | `user.email` | `string?` | User's email address | | `user.nickname` | `string?` | User's display name | | `accessToken` | `string` | Current access token (server-side only) | | `refreshToken` | `string?` | Refresh token | | `accessTokenExpires` | `number` | Token expiry timestamp (ms) | | `zkEvm.ethAddress` | `string?` | zkEVM wallet address | | `zkEvm.userAdminAddress` | `string?` | Admin wallet address | | `error` | `string?` | `"TokenExpired"` or `"RefreshTokenError"` | The client-side session from `useImmutableSession` intentionally omits `accessToken` to prevent use of stale tokens. Use `getAccessToken()` for API calls or `getUser()` for wallet integration. The `idToken` is not stored in the session cookie to stay within CDN header size limits. On the client, `@imtbl/auth-next-client` persists it in `localStorage` so wallet operations can access it via `getUser()`. ## Next.js Exported Utilities The server package exports low-level utilities for manual token handling: ```typescript theme={null} import { isTokenExpired, refreshAccessToken, extractZkEvmFromIdToken, } from "@imtbl/auth-next-server"; ``` | Utility | Description | | ---------------------------------- | -------------------------------------------------------------------- | | `isTokenExpired(expiresAt)` | Check if an access token has expired | | `refreshAccessToken(token)` | Manually refresh tokens using a refresh token | | `extractZkEvmFromIdToken(idToken)` | Extract zkEVM claims (ethAddress, userAdminAddress) from an ID token | ## Environment Configuration | Environment | Chain | API Base | | ------------ | ----------------- | --------------------------- | | `SANDBOX` | Immutable Testnet | `api.sandbox.immutable.com` | | `PRODUCTION` | Immutable Mainnet | `api.immutable.com` | ## Next Steps ### Getting Started Set up credentials and OAuth client in Immutable Hub Initialize Passport and implement login Access wallet addresses and balances Server-side minting for NFTs at scale ### Additional Resources Understand the security model and smart contract wallets Common questions and troubleshooting SDK source code Package registry # Advanced Configuration Source: https://docs.immutable.com/docs/sdks/unity/advanced-configuration Custom WebView, IL2CPP support, and advanced SDK configuration ## Custom Windows WebView The default Unity Web Browser (UWB) doesn't support IL2CPP. For IL2CPP builds or more control, integrate a third-party WebView. ### Vuplex Integration [Vuplex](https://vuplex.com/) is a commercial WebView that supports IL2CPP. #### 1. Install Vuplex 1. Purchase [Vuplex Windows WebView](https://developer.vuplex.com/webview/overview) 2. Import the `.unitypackage` from your Vuplex dashboard 3. Include `Core/` and `Standalone/` directories (skip `Mac/`) #### 2. Implement the Interface Create a class implementing `IWindowsWebBrowserClient`: ```csharp theme={null} #if UNITY_STANDALONE_WIN using Cysharp.Threading.Tasks; using Immutable.Browser.Core; using Vuplex.WebView; public class VuplexWebView : IWindowsWebBrowserClient { public event OnUnityPostMessageDelegate OnUnityPostMessage; private IWebView webView; public VuplexWebView() { webView = Web.CreateWebView(); } public async UniTask Init() { await webView.Init(1, 1); webView.MessageEmitted += (sender, eventArgs) => { OnUnityPostMessage?.Invoke(eventArgs.Value); }; } public void LoadUrl(string url) { webView.LoadUrl(url); } public async void ExecuteJavaScript(string js) { await webView.ExecuteJavaScript(js); } public string GetPostMessageApiCall() { return "window.vuplex.postMessage"; } public void Dispose() { webView.Dispose(); } } #endif ``` #### 3. Initialize with Custom WebView ```csharp theme={null} passport = await Passport.Init(clientId, environment #if UNITY_STANDALONE_WIN || (UNITY_EDITOR_WIN && UNITY_STANDALONE_WIN) , windowsWebBrowserClient: new VuplexWebView() #endif ); ``` #### 4. Configure Scripting Defines 1. Go to **File** → **Build Settings** → **Player Settings** 2. Navigate to **Player** → **Other Settings** 3. Add `IMMUTABLE_CUSTOM_BROWSER` to **Scripting Define Symbols** 4. Click **Apply** This excludes the default WebView from your build. #### 5. Exclude Vuplex from Non-Windows Builds Follow [Vuplex's platform exclusion guide](https://support.vuplex.com/articles/exclude-from-build) to prevent Vuplex from being included in non-Windows builds. *** ## Custom Timeout Configuration Override default timeouts for SDK operations: ```csharp theme={null} // Set custom timeout (default is 60 seconds) passport.SetCallTimeout(TimeSpan.FromMinutes(2)); ``` *** ## Session Persistence The SDK automatically persists sessions. To manually control: ```csharp theme={null} // Check for existing session bool hasSession = await passport.HasCredentialsSaved(); if (hasSession) { // Silent login (no UI) await passport.Login(useCachedSession: true); } else { // Full login flow await passport.Login(); } ``` *** ## Logging The Passport SDK provides logging so you can monitor and troubleshoot its behaviour. You can set the log level and optionally redact sensitive data from logs. ### Log level Use the `LogLevel` property to control the severity of logged messages. The default is `LogLevel.Info`. | Level | Description | | ---------------- | ------------------------------------------- | | `LogLevel.Debug` | Detailed information for debugging | | `LogLevel.Info` | General SDK operation information (default) | | `LogLevel.Warn` | Warnings about potential issues | | `LogLevel.Error` | Errors indicating SDK failures | ```csharp theme={null} // Set log level to Debug for verbose output Passport.LogLevel = LogLevel.Debug; ``` ### Redacting sensitive tokens in logs By default, access and ID tokens are logged in full. Set `RedactTokensInLogs` to `true` to replace them with `[REDACTED]` in log output. ```csharp theme={null} Passport.RedactTokensInLogs = true; ``` Use this when debugging or sharing logs to avoid exposing sensitive data. *** ## Next Steps iOS, Android configuration Common questions # FAQ Source: https://docs.immutable.com/docs/sdks/unity/faq # Frequently Asked Questions Common questions and solutions for platform setup, authentication, errors, and integration with the Unity SDK. ## Platform & Technical Setup Yes, you can test the SDK using the Unity Editor for Android and iOS on both Mac and Windows. However, the native Android and iOS WebViews cannot run in the editor. The macOS WebView is used for the Mac Unity Editor, and the Windows WebView is used for the Windows Unity Editor. | Testing Stage | Environment | Purpose | | ------------- | -------------------- | -------------------------------- | | Development | Unity Editor | Quick iteration on logic | | Integration | Android/iOS Emulator | Test mobile WebView behavior | | Final QA | Physical Device | Test performance & real hardware | Set up emulators early in development. Editor testing alone will miss mobile-specific issues. You're attempting to run an Android/iOS game in the Unity Editor. Native Android/iOS WebViews cannot run in the Unity Editor—the Editor uses desktop WebView (macOS or Windows) instead. You must run your game through an Android/iOS emulator or device, or switch your build target to "PC, Mac & Linux Standalone" in **File → Build Settings** for Editor testing. Always test on real devices before production. Emulators don't catch all issues. Our SDK doesn't provide out-of-the-box support for IL2CPP on Windows. However, you can integrate a third-party Unity Windows WebView that supports IL2CPP, such as [Vuplex](https://developer.vuplex.com/webview/overview). See our [custom Windows WebView documentation](/build/unity/advanced/custom-windows-webview) for detailed instructions. IL2CPP is supported on Android and iOS. Windows requires additional WebView integration. ## Regional Support The Immutable Game SDK supports only the regions that Immutable Passport supports. Passport is a globally available product. However, our wallet infrastructure is subject to the regulation of the US Department of the Treasury's Office of Foreign Assets Control ("OFAC"). OFAC administers and enforces comprehensive and targeted economic and trade sanctions programs on multiple countries and regions. Users attempting to access Passport in any of the regions under OFAC sanction will have their access blocked and will be unable to use our product. Additionally, components of Passport's infrastructure also rely on technology provided by [Magic](https://magic.link/), which maintains further details regarding unsupported regions on their website [here](https://help.magic.link/knowledge/prohibited-regions). ## Authentication This is not possible due to security reasons. Vendors like Google also block login through webviews because the hosting applications can steal credentials. Never use WebView for authentication. It's a security risk and violates OAuth best practices. See the "Why is the in-app browser used for Passport login on mobile and not a webview?" section below for more details. The in-app browser is specifically designed for single sign-on (SSO) purposes, making it a much more secure option. This browser runs on a separate process from the hosting game, meaning the game cannot access it, modify any content, or inject malicious code. | Feature | In-App Browser | WebView | | -------------------------- | ------------------ | --------------------- | | Process isolation | ✅ Separate process | ❌ Same process as app | | App can inject code | ❌ No | ✅ Yes (security risk) | | App can intercept requests | ❌ No | ✅ Yes (security risk) | | Designed for SSO | ✅ Yes | ❌ No | WebView gives the hosting game full control, allowing it to intercept requests and inject JavaScript. This is why OAuth providers like Google block WebView authentication. **Platform implementations:** * Android: [Chrome Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs) * iOS/macOS: [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) To securely implement PKCE, we must use `ASWebAuthenticationSession`, which shows an alert. This alert cannot be removed or modified as the operating system triggers it. This is a security feature designed to protect users from unauthorized authentication attempts. Any attempt to bypass it will cause app rejection. [Learn more about ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) This error occurs because you are attempting to log in using a WebView instead of a browser. Google blocks authentication attempts from WebViews because they can be exploited to steal user credentials. The hosting app has full control over WebView content, including the ability to inject JavaScript and intercept requests. Use the system browser or in-app browser (SFSafariViewController/Chrome Custom Tabs) instead of WebView. The SDK uses the system browser by default. Refer to the "Can I use a WebView instead of the system browser for login?" section above for more information. Custom URL schemes are used because [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) doesn't reliably trigger Universal Links after authentication. Universal Links depend on Safari's verification process, which can be inconsistent. Custom URL schemes ensure immediate redirection to the app and a reliable experience. This approach is secure because [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) ensures that only the calling app receives the callback even if other apps register the same custom URL scheme. This prevents callback interception by other apps and ensures the security of the authentication process. ## Errors & Troubleshooting If you encounter a `TimeoutException`, it indicates that the function you called took more than one minute to return a response. Although this is the default timeout value, the timeout can be customized using the `SetCallTimeout` function. ```csharp theme={null} // Increase timeout globally passport.SetCallTimeout(120); // 2 minutes // For blockchain transactions passport.SetCallTimeout(180); // 3 minutes ``` To identify the specific function that caused this exception, check the bottom of the stack trace or the logs generated before the exception was thrown. For blockchain transactions, consider increasing timeout to 180 seconds (3 minutes) to account for network congestion. Our SDK includes a [post-process script](https://github.com/immutable/unity-immutable-sdk/blob/main/src/Packages/Passport/Editor/PassportPostprocess.cs) that copies an [`index.html`](https://github.com/immutable/unity-immutable-sdk/blob/main/src/Packages/Passport/Runtime/Resources/index.html) file required to run the SDK. This error could be because the files weren't copied correctly to the build. Check if you got the message "Successfully copied Passport files" in the console output during build. Another reason for this error could be that the path to the `index.html` file is unexpected. We try to cover all the possible cases in our SDK, but if you suspect this is the problem, please [create a GitHub issue](https://github.com/immutable/unity-immutable-sdk/issues). These errors are usually caused by missing large files stored in [Git Large File Storage (Git LFS)](https://git-lfs.github.com/). Make sure Git LFS is installed **before** cloning the repository. Versions later than [v1.36.9](https://github.com/immutable/unity-immutable-sdk/releases/tag/v1.36.9) no longer bundle a fork of Unity Web Browser (UWB). Instead, Windows embedded WebView support is provided either by Vuplex WebView or by an optional integration with the official Unity Web Browser packages. You will usually see this error when you're using an older SDK version that still expects bundled UWB binaries and those `.dll` files are missing (for example because Git LFS wasn't installed), or when you've installed Unity Web Browser as an optional dependency but one or more of its engine packages / assemblies are missing or misconfigured. We strongly recommend upgrading to the latest version of the Unity SDK to avoid Git LFS issues entirely. **If using latest SDK:** * If you want to use UWB for Windows desktop, follow the [WebView Setup](/build/unity/usage/passport/setup#webview-setup) docs * Otherwise, rely on Vuplex WebView for embedded flows **If using older SDK (\< v1.36.9):** * Install Git LFS before cloning the repository so that the UWB `.dll` files are downloaded correctly * If the issue persists, reinstall Git LFS and try a fresh clone This error is commonly caused by using an incompatible Application Entry Point. The Immutable SDK only supports `Activity`, not `GameActivity`. Try the following: 1. Set your [Application Entry Point](https://docs.unity3d.com/6000.0/Documentation/Manual/android-application-entries-set.html) to `Activity` in **Project Settings** → **Player** → **Android** → **Other Settings** → **Configuration** → **Application Entry Point** 2. Open your `AndroidManifest.xml` file and remove the `UnityPlayerGameActivity` activity block, keeping only the `UnityPlayerActivity` block Unity 6 changed the default Application Entry Point to GameActivity. You must manually change it back to Activity for Immutable SDK compatibility. If this doesn't resolve the issue, please [raise an issue](https://github.com/immutable/unity-immutable-sdk/issues) in the Immutable Unity SDK repository. Android minification can obfuscate SDK classes, breaking authentication. Please refer to [this](/x/sdks/unity#proguard) section of our documentation for the required ProGuard configuration. Always test with minification enabled before production to catch ProGuard issues early. Please try the following items to see if the issue goes away: 1. Before starting your game, check the Task Manager for Chrome Embedded Framework (CEF) and kill it if it's running. Our code handles this, but you may need to do it manually. 2. Restart your computer. 3. Increase the engine start-up timeout in the `Passport.Init` function [here](https://github.com/immutable/unity-immutable-sdk/blob/98eabc0a140619cf0f1526792cd746e48d42d934/src/Packages/Passport/Runtime/Scripts/Public/Passport.cs#L56). Our code attempts to kill CEF automatically, but you may need to do it manually in some cases. This error indicates that Unity Web Browser's Chrome Embedded Framework (CEF) engine is not installed correctly or cannot be found at runtime. To fix this, ensure that you have installed the correct Unity Web Browser packages for Windows: * `dev.voltstro.unitywebbrowser` * `dev.voltstro.unitywebbrowser.engine.cef` * `dev.voltstro.unitywebbrowser.engine.cef.win.x64` (for 64-bit Windows builds) After installing these packages, re-open your project so Unity can import the assets and regenerate any required configuration. UWB is an optional, Windows-only dependency. If it is not installed, Passport will fall back to using the system browser or any custom `IWindowsWebBrowserClient` implementation you provide. ## Integration & Usage The [Unity SDK sample](https://github.com/immutable/unity-immutable-sdk/tree/main/sample) is a working project that demonstrates how to use the main SDK functions. Use it as a reference when integrating the SDK into your own game. **Additional resources:** * [Unity SDK Reference](/reference/unity) - Complete API documentation You can use the zkEVM Send Transaction function in the SDK to call your smart contract crafting function. Crafting typically involves calling a smart contract that burns input NFTs and mints a new crafted NFT. Yes! The Unity and Unreal SDKs share a common architecture that you can adapt for custom game engines. See the [Game Bridge Architecture](/docs/sdks/game-bridge-architecture) documentation for more details. *** ## Still Need Help? Complete Unity SDK documentation Report Unity SDK issues Contact player support Browse all documentation # Unity SDK Source: https://docs.immutable.com/docs/sdks/unity/overview Native C# bindings for Passport authentication, wallet operations, and blockchain data queries. **Who is this for?** Game developers using Unity who want to integrate web3 features, NFTs, and blockchain functionality into their games. ## Platform Compatibility | Unity Version | Windows | macOS | Android | iOS | | --------------- | -------------- | -------------- | -------------- | -------------- | | **Unity 6** | ✅ Full support | ✅ Full support | ✅ Full support | ✅ Full support | | **2021.3 LTS+** | ✅ Full support | ✅ Full support | ✅ Full support | ✅ Full support | **Unity 6 Android Users**: If using Unity 6 with Android, you must set the Application Entry Point to `Activity` (not the default `GameActivity`). See [Unity 6 Configuration](#unity-6-configuration) below. ## Prerequisites * **Unity**: 2021.3 LTS or later * **.NET Standard**: 2.1 * **UniTask**: The [UniTask](https://github.com/Cysharp/UniTask) package (v2.3.3) is a required dependency of our SDK. Follow the instructions [here](https://github.com/Cysharp/UniTask#upm-package) to install it. * **Git LFS**: The SDK repository uses [Git Large File Storage](https://git-lfs.github.com/) to manage `.dll` files and other binary assets. Install Git LFS before cloning or Unity Package Manager may fail to download binary dependencies correctly. ## Installation 1. Install [UniTask](https://github.com/Cysharp/UniTask#upm-package) v2.3.3 or later The UniTask package is a dependency of our SDK. 2. Install [Git LFS](https://git-lfs.github.com/) Git LFS must be installed **before** adding the SDK. The SDK contains `.dll` files stored in Git LFS that won't download correctly otherwise. 3. Add the Immutable SDK: 1) Open **Window** → **Package Manager** 2) Click **+** and select **Add package from git URL...** 3) Enter `https://github.com/immutable/unity-immutable-sdk.git?path=/src/Packages/Passport` and click **Add** 1. Open your project's `Packages/manifest.json` file 2. Add the following in the `dependencies` block: ```json theme={null} "com.immutable.passport": "https://github.com/immutable/unity-immutable-sdk.git?path=/src/Packages/Passport" ``` #### Install a Specific Version To install a specific version of the SDK using either method above, append `#` followed by the version tag to the git URL: ``` https://github.com/immutable/unity-immutable-sdk.git?path=/src/Packages/Passport#v1.0.0 ``` For mobile platform setup (Android, iOS), see [Platform-Specific Configuration](/docs/sdks/unity/overview#platform-specific-configuration). ### Unity 6 Configuration **Unity 6 Android Required**: If using Unity 6 with Android, you must configure the Application Entry Point. 1. Go to **Project Settings** → **Player** → **Android** → **Other Settings** → **Configuration** 2. Set **Application Entry Point** to `Activity` (not `GameActivity`) 3. Open `AndroidManifest.xml` and remove the `UnityPlayerGameActivity` activity block; keep only the `UnityPlayerActivity` block ## Platform-Specific Configuration ### Platform Requirements * **Windows**: 10 or later ### Scripting Backend Support | Backend | Support | Notes | | ---------- | -------------------------- | -------------------------------------------------------------------------------------- | | **Mono** | ✅ Fully supported | Uses default Unity Web Browser (UWB) | | **IL2CPP** | ⚠️ Requires custom WebView | See [Advanced Configuration](/docs/sdks/unity/advanced-configuration) for Vuplex setup | ### Default WebView Setup The SDK uses [Unity Web Browser (UWB)](https://github.com/Voltstro-Studios/UnityWebBrowser) by default on Windows. UWB is not bundled with the SDK, so you must install it. 1. Add the VoltUPR registry and required scopes, then install UWB via the Package Manager. See the [UWB GitHub repo](https://github.com/Voltstro-Studios/UnityWebBrowser) and [VoltUPR setup guide](https://upr.voltstro.dev/-/web/about). 2. Import these packages: * `dev.voltstro.unitywebbrowser` * `dev.voltstro.unitywebbrowser.engine.cef` * `dev.voltstro.unitywebbrowser.engine.cef.win.x64` 3. Confirm **UWB\_WEBVIEW** is present in your project after the packages are installed. For IL2CPP builds, use a custom WebView like Vuplex. See [Advanced Configuration](/docs/sdks/unity/advanced-configuration). ### Platform Requirements * **macOS**: 12.5 or later No additional configuration required for macOS. ### Platform Requirements * **Minimum Android Version**: 10 or later (API Level 24+) * **Target API**: 33 or higher recommended ### Enable custom manifest and Gradle 1. Go to **Edit** → **Project Settings** → **Player** → **Android** 2. Expand **Publishing Settings** 3. In **Build**, enable **Custom Main Manifest** and **Custom Main Gradle Template** Unity generates `Assets/Plugins/Android/AndroidManifest.xml` and `Assets/Plugins/Android/mainTemplate.gradle`. Edit these files (or your existing custom ones) as below. ### AndroidManifest.xml Add this activity inside the `` element. Set `android:scheme` and `android:host` to match the redirect URIs you use in Passport (e.g. `mygame` and `callback` for `mygame://callback`): ```xml theme={null} ``` ### mainTemplate.gradle Add the Chrome Custom Tabs dependency inside the `dependencies` block: ```gradle theme={null} implementation('androidx.browser:browser:1.5.0') ``` For `androidx.browser:browser:1.5.0`, **Target API Level** must be at least 33. Set it under **Edit** → **Project Settings** → **Player** → **Android** → **Other Settings** → **Target API Level**. ### Manual Manifest Configuration (Advanced) If automatic merging doesn't work for your project, you can manually configure `AndroidManifest.xml`: ```xml theme={null} ``` ### Platform Requirements * **iOS Version**: 15.2 or later ### URL Scheme Configuration Add your custom URL scheme to `Info.plist`: ```xml theme={null} CFBundleURLTypes CFBundleURLSchemes mygame ``` Or configure in Unity: 1. Go to **Edit** → **Project Settings** → **Player** → **iOS** → **Other Settings** → **Supported URL schemes** 2. Increase **Size** by 1 3. Enter your URL scheme in the new **Element** slot (e.g. `mygame` for `mygame://callback`) The SDK handles deep links automatically. Ensure your redirect URIs match your configured URL scheme. Your redirect URIs (like `mygame://callback`) must use the same URL scheme that you configure in your platform settings: * **iOS**: Only the scheme needs to match (e.g., `mygame` in `mygame://callback`) * **Android**: Both the scheme AND host must match (e.g., `mygame` and `callback` in `mygame://callback`) See [Initialize Passport](/docs/products/passport/authentication#initialize-passport) for authentication setup. ```csharp theme={null} // Must match your URL scheme passport = await Passport.Init ( clientId: "YOUR_CLIENT_ID", environment: Environment.Sandbox, redirectUri: "mygame://callback", // ← mygame matches URL scheme config logoutRedirectUri: "mygame://logout" ); ``` ## Next Steps ### Getting Started Set up credentials and OAuth client in Immutable Hub Initialize Passport and implement login Access wallet addresses and balances Understand SDK internals and custom engine integration ### Additional Resources Custom WebView, IL2CPP support Common questions and troubleshooting SDK source code and samples # FAQ Source: https://docs.immutable.com/docs/sdks/unreal/faq # Frequently Asked Questions Common questions and solutions for authentication, BLUI, platform issues, and integration with the Unreal SDK. ## Platform & Technical Setup As of 6 May 2025, PKCE is supported on Windows (alpha release). Prior to this date, PKCE was only available on Android, iOS, and macOS. | Date | Event | | ------------------ | -------------------------------- | | Before May 6, 2025 | PKCE only on Android, iOS, macOS | | May 6, 2025 | PKCE Windows support (alpha) | | July 1, 2025 | Device Code Authorization sunset | Device Code Authorization will be sunset on July 1, 2025 across all platforms. Migrate to PKCE before this date. We strongly recommend developers begin migrating to PKCE to ensure a smooth transition before the sunset date. Both BLUI and Epic's WebBrowserWidget plugin utilise the Chromium Embedded Framework (CEF) in the background. The Immutable plugin requires CEF to run its internal libraries, including player/gamer authentication procedures. In Unreal Engine 4, the CEF libraries are outdated and cannot execute the source code of the internal libraries. Therefore, we require the BLUI plugin to be used instead of the WebBrowserWidget plugin. | Platform | CEF Version Needed | UE4 WebBrowserWidget | BLUI Plugin | | --------- | ------------------ | -------------------- | ------------ | | Windows | CEF 90+ | ❌ Outdated | ✅ Up-to-date | | Android | N/A | ✅ Native WebView | N/A | | iOS/macOS | N/A | ✅ WKWebView | N/A | Unreal Engine 5 may have updated CEF libraries. Check your UE version's CEF version if using UE5. This issue is known and occurs with Unreal Engine 4.26 and 4.27. If you are having trouble running the [Xsolla](https://github.com/xsolla/store-ue4-sdk) plugin with the Immutable Unreal SDK and [BLUI](https://github.com/immutable/BLUI-Unreal/tree/Imtbl4.2) plugins enabled, it is because of a conflict between Chromium Embedded Framework browser processes. Our Immutable plugin needs the [BLUI](https://github.com/immutable/BLUI-Unreal/tree/Imtbl4.2) plugin to be enabled (see the "Why do I need to disable the WebBrowserWidget plugin to use BLUI?" section above). However, the [Xsolla](https://github.com/xsolla/store-ue4-sdk) plugin also requires Chromium Embedded Framework to run and relies on Epic's WebBrowser module for that, which leads to conflicts when both plugins are used together. | Plugin | CEF Requirement | Implementation | | ------------- | ---------------------- | -------------------------- | | Immutable SDK | Requires BLUI | Uses BLUI's CEF | | Xsolla SDK | Requires WebBrowser | Uses Epic's WebBrowser CEF | | Result | ❌ Both plugins enabled | CEF process conflict | This is a known limitation with UE 4.26 and 4.27. No perfect solution exists without engine or plugin modifications. **Workarounds:** 1. Use only one plugin at a time (not ideal for production) 2. Upgrade to Unreal Engine 5 (may resolve CEF conflicts) 3. Contact Xsolla support for alternative integration methods ## Regional Support The Immutable Game SDK supports only the regions that Immutable Passport supports. Passport is a globally available product. However, our wallet infrastructure is subject to the regulation of the US Department of the Treasury's Office of Foreign Assets Control ("OFAC"). OFAC administers and enforces comprehensive and targeted economic and trade sanctions programs on multiple countries and regions. Users attempting to access Passport in any of the regions under OFAC sanction will have their access blocked and will be unable to use our product. Additionally, components of Passport's infrastructure also rely on technology provided by [Magic](https://magic.link/), which maintains further details regarding unsupported regions on their website [here](https://help.magic.link/knowledge/prohibited-regions). ## Authentication This is not possible due to security reasons. Vendors like Google also block login through webviews because the hosting applications can steal credentials. Never use WebView for authentication. It's a security risk and violates OAuth best practices. See the "Why is the in-app browser used for Passport login on mobile and not a webview?" section below for more details. The in-app browser is specifically designed for single sign-on (SSO) purposes, making it a much more secure option. This browser runs on a separate process from the hosting game, meaning the game cannot access it, modify any content, or inject malicious code. | Feature | In-App Browser | WebView | | -------------------------- | ------------------ | --------------------- | | Process isolation | ✅ Separate process | ❌ Same process as app | | App can inject code | ❌ No | ✅ Yes (security risk) | | App can intercept requests | ❌ No | ✅ Yes (security risk) | | Designed for SSO | ✅ Yes | ❌ No | WebView gives the hosting game full control, allowing it to intercept requests and inject JavaScript. This is why OAuth providers like Google block WebView authentication. **Platform implementations:** * Android: [Chrome Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs) * iOS/macOS: [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) To securely implement PKCE, we must use `ASWebAuthenticationSession`, which shows an alert. This alert cannot be removed or modified as the operating system triggers it. This is a security feature designed to protect users from unauthorized authentication attempts. Any attempt to bypass it will cause app rejection. [Learn more about ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) This error occurs because you are attempting to log in using a WebView instead of a browser. Google blocks authentication attempts from WebViews because they can be exploited to steal user credentials. The hosting app has full control over WebView content, including the ability to inject JavaScript and intercept requests. Use the system browser or in-app browser (SFSafariViewController/Chrome Custom Tabs) instead of WebView. The SDK uses the system browser by default. Refer to the "Can I use a WebView instead of the system browser for login?" section above for more information. Custom URL schemes are used because [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) doesn't reliably trigger Universal Links after authentication. Universal Links depend on Safari's verification process, which can be inconsistent. Custom URL schemes ensure immediate redirection to the app and a reliable experience. This approach is secure because [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) ensures that only the calling app receives the callback even if other apps register the same custom URL scheme. This prevents callback interception by other apps and ensures the security of the authentication process. ## Integration & Usage You can use the zkEVM Send Transaction function in the SDK to call your smart contract crafting function. Crafting typically involves calling a smart contract that burns input NFTs and mints a new crafted NFT. Yes! The Unity and Unreal SDKs share a common architecture that you can adapt for custom game engines. See the [Game Bridge Architecture](/docs/sdks/game-bridge-architecture) documentation for more details. ## Still Need Help? If your question isn't answered here, check the [Unreal SDK overview](/docs/sdks/unreal/overview) for implementation guides. ## Next Steps Return to SDK documentation Learn about Passport authentication Manage publishable and secret keys Integrate wallet funding # Unreal SDK Source: https://docs.immutable.com/docs/sdks/unreal/overview Native C++ and Blueprint support for Passport authentication, wallet operations, and blockchain data queries. **Who is this for?** Game developers using Unreal Engine who want to integrate web3 features, NFTs, and blockchain functionality into their games. ## Platform Compatibility | Unreal Engine Version | Windows | macOS | iOS | Android | | --------------------- | --------------- | --------------- | --------------- | --------------- | | **5.5.x** | ✅ Full support | ✅ Full support | ⚠️ Beta | ⚠️ Beta | | **5.4.x** | ✅ Full support | ✅ Full support | ⚠️ Beta | ⚠️ Beta | | **5.3.x** | ✅ Full support | ✅ Full support | ⚠️ Beta | ⚠️ Beta | | **5.2.x and below** | ❌ Not supported | ❌ Not supported | ❌ Not supported | ❌ Not supported | **Platform Requirements**: Requirements depend on your Unreal Engine version. Use the version selector in Unreal's documentation to view requirements for your engine: * [Windows](https://dev.epicgames.com/documentation/en-us/unreal-engine/setting-up-visual-studio-development-environment-for-cplusplus-projects-in-unreal-engine) * [macOS](https://dev.epicgames.com/documentation/en-us/unreal-engine/macos-development-requirements-for-unreal-engine) * [Android](https://dev.epicgames.com/documentation/en-us/unreal-engine/android-support-for-unreal-engine) * [iOS](https://dev.epicgames.com/documentation/en-us/unreal-engine/setting-up-an-unreal-engine-project-for-ios) **Legacy Versions (4.26, 4.27, 5.0)**: These versions are **not officially supported** and require the BLUI plugin workaround. We strongly recommend using **UE 5.3+** for new projects. See the **Legacy Versions** accordion below. **Mobile Platforms (iOS & Android)**: Not officially supported. ## Prerequisites * **Unreal Engine**: 5.3 or later (5.4 and 5.5 recommended) * **Git LFS**: The SDK repository uses [Git Large File Storage](https://git-lfs.github.com/) to manage `.uasset` and `.umap` files. Install Git LFS before cloning or the plugin may fail to download binary dependencies correctly. ## Installation 1. Install [Git LFS](https://git-lfs.github.com/) Git LFS must be installed **before** adding the SDK. The SDK contains `.uasset` and `.umap` files stored in Git LFS that won't download correctly otherwise. 2. Add the SDK to your project's `Plugins` directory: From your project's root directory: ```bash theme={null} git clone https://github.com/immutable/unreal-immutable-sdk.git Plugins/unreal-immutable-sdk ``` From your project's root directory: ```bash theme={null} git submodule add https://github.com/immutable/unreal-immutable-sdk.git Plugins/unreal-immutable-sdk ``` 1. Download from [GitHub Releases](https://github.com/immutable/unreal-immutable-sdk/releases) 2. Extract the archive 3. Copy to your project's `Plugins/unreal-immutable-sdk` folder 3. Restart the Unreal Editor **Legacy Unreal Engine Versions (4.26, 4.27, 5.0)**: Additional BLUI plugin installation required. See [Legacy Versions Setup](/docs/sdks/unreal/overview#platform-specific-configuration) in the overview. Your project structure should look like: ``` Root/ └── Plugins/ └── unreal-immutable-sdk/ ``` 4. Enable the Immutable module in your Build.cs: ```cpp title="Source/YourGame/YourGame.Build.cs" theme={null} PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "Immutable" // Add this }); ``` For mobile platforms, see [Platform Setup](/docs/sdks/unreal/overview#platform-specific-configuration). **Not Officially Supported**: Unreal Engine 4.26, 4.27, and 5.0 are not officially supported. We strongly recommend using UE 5.3+ for new projects. For Unreal Engine 4.26, 4.27, and 5.0, you must install the BLUI plugin because Epic's `WebBrowserWidget` uses an outdated CEF version incompatible with the SDK. More on why we use BLUI plugin can be found in the [FAQ](/docs/sdks/unreal/faq). **1. Download BLUI** Download or clone from [immutable-BLUI](https://github.com/immutable/immutable-BLUI) to your project's `Plugins` folder. **2. Rename the folder** Rename `immutable-BLUI` to `BLUI`. Your plugins directory should look like: ``` MyGame/ └── Plugins/ ├── BLUI/ └── unreal-immutable-sdk/ ``` **3. Disable WebBrowserWidget** Edit `unreal-immutable-sdk/Immutable.uplugin` and set `WebBrowserWidget` to disabled: ```json theme={null} { "Plugins": [ { "Name": "WebBrowserWidget", "Enabled": false } ] } ``` **4. Configure Hub** **Hub Configuration Required**: In [Immutable Hub](https://hub.immutable.com), you MUST add `file://*` to the **Web Origin URLs** field in your Passport client configuration. Without this, the BLUI embedded browser cannot communicate with the Passport SDK and authentication will fail. 1. Navigate to your project in Immutable Hub 2. Go to **Passport** → **Clients** → Select your client 3. Add `file://*` to the **Web Origin URLs** field 4. Save changes ## Platform-Specific Configuration **Unreal Engine 5.3+**: This version uses the [Modern Xcode workflow](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-modern-xcode-in-unreal-engine) with per-project configuration files. Follow the platform-specific instructions below for your target platforms. ### Platform Requirements * **Windows**: 10 or later No additional configuration required for Windows builds. ### Platform Requirements * **macOS**: 10.15 or later ### URL Scheme Configuration 1. Locate your project's `.plist` template file at `/Build/Mac/Resources/Info.Template.plist` * You can find the path in **Mac: Info.plist Template** in Project Settings (shows as `/Game/Build/Mac/Resources/Info.Template.plist`) * If it doesn't exist, Unreal will create it when you first generate project files 2. Add the following inside the root `...`: ```xml theme={null} CFBundleURLTypes CFBundleURLSchemes mygame ``` **Using a custom file**: You can create your own `.plist` file and update **Mac: Info.plist Template** in Project Settings to point to your custom file for more control. Editing the engine's `Info.plist` at `Engine/Source/Runtime/Launch/Resources/Mac/Info.plist` will affect all projects using that engine installation. Use project-specific `.plist` files instead. See [Epic's Modern Xcode docs](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-modern-xcode-in-unreal-engine#.plistfiles) for details. ### Platform Requirements * **Minimum Android Version**: 10 or later (API Level 24+) * **Target API**: 33 or higher recommended ### Deep Link Configuration 1. Create `Source//_UPL_Android.xml`: ```xml theme={null} ``` 2. Modify `Source//.Build.cs`: ```csharp theme={null} public class YourProject : ModuleRules { public YourProject(ReadOnlyTargetRules Target) : base(Target) { if (Target.Platform == UnrealTargetPlatform.Android) { string PluginPath = Utils.MakePathRelativeTo(ModuleDirectory, Target.RelativeEnginePath); AdditionalPropertiesForReceipt.Add("AndroidPlugin", Path.Combine(PluginPath, "YourProject_UPL_Android.xml")); } } } ``` ### Platform Requirements * **iOS Version**: 15.2 or later ### URL Scheme Configuration 1. Go to **Project Settings** → **Platforms** → **iOS** 2. Under **Extra PList Data**, add: ```xml theme={null} CFBundleURLTypes CFBundleURLSchemes mygame ``` 1. Locate your project's `.plist` template file at `/Build/IOS/UBTGenerated/Info.Template.plist` * You can find the path in **IOS / TVOS: Info.plist Template** in Project Settings (shows as `/Game/Build/IOS/UBTGenerated/Info.Template.plist`) 2. Add the following inside the root `...`: ```xml theme={null} CFBundleURLTypes CFBundleURLSchemes mygame ``` **Using a custom file**: If you want to avoid UBT potentially overwriting the default template, create your own `.plist` file (you can use `Info.Template.plist` as a starting point) and update **IOS / TVOS: Info.plist Template** in Project Settings to point to your custom file. UBT auto-generates the default iOS template and may overwrite changes. See [Epic's Modern Xcode docs](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-modern-xcode-in-unreal-engine#.plistfiles) for details. 1. Create a complete `.plist` file at `/Build/IOS/Resources/MyGameIOS.plist` 2. Add your URL scheme and all required iOS app settings: ```xml theme={null} CFBundleURLTypes CFBundleURLSchemes mygame ``` 3. Edit `/Config/DefaultEngine.ini` and add: ```ini theme={null} [/Script/MacTargetPlatform.XcodeProjectSettings] PremadeIOSPlist=(FilePath="/Game/Build/IOS/Resources/MyGameIOS.plist") ``` **Premade vs Template**: A premade `.plist` is a complete, finalized file that Xcode will NOT modify. This gives you absolute control but requires managing all iOS app settings yourself. See [Epic's Modern Xcode docs](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-modern-xcode-in-unreal-engine#useapremade.plist) for details. The SDK handles deep links automatically. Ensure your redirect URIs match your configured URL scheme. Your redirect URIs (like `mygame://callback`) must use the same URL scheme that you configure in your platform settings: * **iOS**: Only the scheme needs to match (e.g., `mygame` in `mygame://callback`) * **Android**: Both the scheme AND host must match (e.g., `mygame` and `callback` in `mygame://callback`) See [Initialize Passport](/docs/products/passport/authentication#initialize-passport) for authentication setup. ```cpp theme={null} auto InitData = FImmutablePassportInitData { TEXT("YOUR_CLIENT_ID"), TEXT("mygame://callback"), TEXT("mygame://logout"), TEXT("sandbox"), ... }; Passport->Initialize(InitData, ...); ``` ## Next Steps ### Getting Started Set up credentials and OAuth client in Immutable Hub Initialize Passport and implement login Access wallet addresses and balances Understand SDK internals and custom engine integration ### Additional Resources Common questions and troubleshooting SDK source code and examples