# 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
# Health check endpoint
Source: https://docs.immutable.com/api-reference/health/health-check-endpoint
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/heartbeat
# 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
# 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
# Build a Game
Source: https://docs.immutable.com/docs/guides/build-a-game
**Sample Project** — [Typescript](https://github.com/immutable/ts-immutable-sdk/tree/main/examples)
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 on tokens sharing a specific metadata ID
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
Supercharge your game's growth with Immutable's next-generation infrastructure
## Why Immutable?
Built-in growth tools, cross-game discovery, and player analytics. Reach millions of players across the Immutable ecosystem.
Players sign in with Google, Apple, or email. Wallets created automatically -- no extensions, no seed phrases, no friction.
Assets secured by Immutable Chain. Zero gas fees for players. Better conversion and retention through real digital ownership.
Auth, wallets, minting, trading, and data APIs—all with SDKs for TypeScript, Unity, and Unreal Engine.
## Core Products
Growth operating system for your game
Authentication and embedded wallets
Payments, swaps, bridges, and onramps
EVM-compatible chain for gaming
NFTs and in-game currencies
Decentralized trading
On-chain data via APIs and webhooks
Project management dashboard
## 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 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/hub/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/products/hub/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
# Amplify
Source: https://docs.immutable.com/docs/products/audience/amplify
Content creators are a key acquisition channel for games. Immutable Amplify helps you find, manage, and measure creator partnerships with [Attribution](/docs/products/audience/attribution) built in.
## Why Amplify?
Creator marketing offers unique advantages for game launches:
| Traditional Ads | Creator Marketing |
| ------------------------ | --------------------------------- |
| Anonymous impressions | Trusted recommendations |
| High CPM, low engagement | Lower cost, higher trust |
| No community building | Brings their community to you |
| Hard to measure | Full attribution through Audience |
## How It Works
Browse Immutable's creator network or bring your own partnerships
Create tracked referral links for each creator
See signups, engagement, and quality scores by creator
Compensate based on actual performance, not just impressions
## Creator Network
Access gaming creators across platforms:
Twitch, YouTube Live, Kick
YouTube, TikTok, Instagram
Discord servers, Reddit, Twitter
## Attribution
Track creator performance with full visibility:
| Metric | Description |
| --------------------------- | ------------------------------------------- |
| **Signups** | Players referred by this creator |
| **Quality Score** | Based on engagement and predicted value |
| **Tier Progress** | Referred players reaching higher tiers |
| **Referral Depth** | Second-degree referrals from their audience |
| **Cost per Quality Signup** | True ROI including quality scoring |
## Campaign Types
Paid placements in videos and streams. Full creative control with your approval. Payment on delivery.
Revenue share based on signups or conversions. Lower upfront cost, aligned incentives.
Give creators exclusive rewards to distribute to their audience. Drives urgency and engagement.
Let creator audiences complete special [quests](/docs/products/audience/questing) with unique rewards.
## Tracking Setup
Every creator gets unique attribution:
```yaml theme={null}
Creator: @GameStreamer123
Referral Link: play.immutable.com/games/your-game?ref=gs123
UTM: utm_source=creator&utm_campaign=gs123
Tracking:
- Click to signup conversion
- Signup to engaged player
- Engagement quality score
- Secondary referrals
```
## Performance Dashboard
Compare creator effectiveness:
```
Creator Signups Quality Cost/Signup ROI
@TopStreamer 2,400 85% $1.20 4.2x
@GameReviewer 1,100 92% $2.30 3.8x
@DiscordMod 450 78% $0.80 5.1x
@TikTokGamer 3,200 62% $0.90 2.1x
```
## Compensation
Flexible payment options:
One-time payment for content. Best for established creators.
Pay per signup or conversion. Best for performance alignment.
Ongoing percentage of referred player spending. Best for long-term partnerships.
Base fee plus performance bonus. Best of both worlds.
## Quality Filtering
Not all creator traffic is equal. Amplify automatically scores:
* **Bot detection**: Filter artificial signups
* **Engagement prediction**: Score based on historical patterns
* **Value prediction**: Likely spending based on cross-game data
Pay for quality signups, not raw numbers.
## Integration with Game Page
Creator referrals land on your [Game Page](/docs/products/audience/game-page) with full attribution:
```
Creator link clicked → Game Page with ref tracking →
Player signs up → Attribution recorded →
Quest completion → Engagement tracked →
Creator credited for quality signup
```
Manage creator partnerships in Hub
Immutable's creator network is available to partners with managed relationships. [Contact us](https://www.immutable.com/contact) for access.
## Next Steps
Set up your pre-launch landing page
Track creator performance and ROI
Create quests for creator campaigns
# Attribution
Source: https://docs.immutable.com/docs/products/audience/attribution
Traditional marketing platforms tell you click counts. Immutable Audience tells you who your players actually are—their motivations, spending patterns, and likelihood to convert.
## The Data Network
We build unified player profiles across all games on Immutable using:
| Data Source | What We Learn |
| ----------------------- | ---------------------------------------------------- |
| **Play Activity** | Engagement patterns, session length, retention |
| **Linked Accounts** | Discord, Twitter, Telegram presence and activity |
| **Transaction Data** | Spending patterns, wallet balances, purchase history |
| **Historical Activity** | Behavior in other Immutable games |
| **On-Chain Data** | NFT holdings, trading patterns, DeFi activity |
## Player Profiles
Every player on your [Game Page](/docs/products/audience/game-page) gets a rich profile:
```yaml theme={null}
Player: alice_gamer
Segments: [whale, early_adopter, competitive]
Platforms: [discord, twitter, telegram]
Lifetime Value: $2,400 (across 3 games)
Engagement: High (daily active)
Conversion Likelihood: 92%
Best Contact Channel: Email
Optimal Send Time: 6pm UTC
```
## Attribution Models
Track where your most valuable players come from:
Know which channels drive signups: paid ads, organic social, influencer campaigns, referrals, or direct traffic.
Don't just count signups—track which sources produce paying players and high-engagement users.
See the full journey: Twitter impression → Discord join → Game Page signup → Email convert.
Compare player groups by acquisition source, signup date, or behavior patterns.
## Audience Insights
Detect bot accounts, farmers, and low-quality traffic before you waste marketing spend
Know where your players are—avoid discovering your entire audience is from a single low-value region
Predict which players are likely to spend based on cross-game behavior
Predict which players will convert to active users at launch
## Paid Marketing Integration
Supercharge your paid campaigns with Audience data:
### Exclusion Lists
Stop wasting money on users who won't convert:
* Known bots and farmers
* Low-engagement accounts
* Already-converted players
* Users from underperforming segments
### Lookalike Audiences
Find more players like your best ones:
* Export high-value player segments
* Create lookalike audiences on Facebook, TikTok
* Improve ROAS on paid campaigns
### Retargeting
Bring back users who've gone cold:
* Custom audiences of lapsed signups
* Personalized messaging by segment
* Optimal timing based on behavior
### Conversion Reporting
Close the loop with the ad networks driving your spend:
* Send attributed game launches and purchases back to TikTok Ads and Reddit Ads
* Improve campaign optimisation with real conversion signal, not just clicks
* Configured server-to-server in Audience Hub, no extra tags or pixels required
See [Conversion Postbacks](/docs/products/audience/conversion-postbacks) for setup details.
## ROI Calculation
Know exactly what your marketing spend produces:
```
Campaign: Summer Launch Push
Spend: $10,000
Traditional Metrics:
- 50,000 impressions
- 2,000 clicks
- 400 wishlists
- ROI: ??? (no visibility post-wishlist)
Audience Metrics:
- 50,000 impressions
- 2,000 clicks
- 400 Game Page signups
- 320 qualified players (80% quality score)
- Predicted LTV: $15,200
- Predicted ROI: 52%
```
## Dashboard
Access insights through your [Hub](/docs/products/hub/overview) dashboard:
| View | Description |
| --------------- | ------------------------------------------ |
| **Overview** | Signups, conversion, engagement trends |
| **Sources** | Attribution by channel with quality scores |
| **Segments** | Player breakdown by behavior and value |
| **Campaigns** | Performance by marketing campaign |
| **Predictions** | Forecast launch performance |
Access attribution data in Hub
## Next Steps
Add a snippet to your site to start collecting attribution data
Track attribution, events, and user identity from your web app
In-game tracking for Unity desktop builds
Send events from your backend or game server
Send attributed conversions back to ad networks to improve campaign optimisation
Set up your player landing page
Use insights for targeted campaigns
Learn about the full Audience suite
# Conversion Postbacks
Source: https://docs.immutable.com/docs/products/audience/conversion-postbacks
Automatically send attributed conversion data back to your ad networks so their algorithms learn which campaigns produce real players
Conversion Postbacks are currently in **alpha**. Behavior and supported networks may change between releases.
**Who is this for?** Marketers running paid acquisition on TikTok Ads or Reddit Ads who want to feed conversion data back to those platforms to improve campaign optimisation.
When a player clicks your TikTok Ads or Reddit Ads ad, installs the game, and makes a purchase, Immutable Audience captures the whole journey. Conversion postbacks close the loop by automatically notifying the ad network that the click led to a real outcome.
Without postbacks, ad networks are flying blind. Their optimisation algorithms can see your spend but not your results. With postbacks enabled, every attributed conversion you send back directly improves your campaign targeting.
## How It Works
Immutable's tracking pixel captures the click ID from your TikTok Ads or Reddit Ads campaign on your marketing site.
Your game fires a conversion event via the Unity SDK, Web SDK, or REST API: game launch or purchase.
The Audience attribution engine matches the in-game event to the original ad click.
Immutable sends a server-to-server callback to the ad network with the conversion data. No code changes needed after initial setup.
## Supported Networks
TikTok Events API, configured directly in Audience Hub
Reddit Conversions API v3, configured directly in Audience Hub
Additional ad networks are on the roadmap. Reach out to your Immutable account manager to discuss your needs.
## What You Need
Before configuring postbacks for a game:
* The Immutable [Tracking Pixel](/docs/products/audience/tracking-pixel) or [Web SDK](/docs/products/audience/web-sdk) is installed on your marketing site (captures click IDs from ad platforms)
* The [Unity SDK](/docs/products/audience/unity-sdk), [Web SDK](/docs/products/audience/web-sdk), or [REST API](/docs/products/audience/rest-api) is sending `game_launch` and/or `purchase` events
* You have admin access to the game in [Audience Hub](https://hub.immutable.com)
## Set Up TikTok Ads Postbacks
### Step 1: Get your TikTok Ads credentials
You'll need two things from TikTok Events Manager:
1. **Access Token**: In [TikTok Events Manager](https://ads.tiktok.com/i18n/events_manager), go to your pixel, then **Settings**, then **Generate Access Token**. Copy the token immediately; it's only shown once. You need Business Center admin rights on the pixel to do this.
2. **Pixel Code**: The pixel code is shown in Events Manager alongside the pixel name (not the pixel name itself).
The access token generated from Events Manager is long-lived and doesn't expire unless manually revoked. We recommend generating it from a service account rather than a personal admin account to avoid it expiring when someone leaves the team.
### Step 2: Configure in Hub
1. In [Audience Hub](https://hub.immutable.com), navigate to **Settings** → **Integrations** → **TikTok Ads**.
2. In the **Conversion postbacks** section, click **+ Add postback**.
3. Enter your **Access token** and **Pixel code**.
4. (Optional) Enter a **Test event code** from TikTok Events Manager. Hub uses this code when verifying credentials on save, and routes any events marked `test: true` by the SDK to TikTok Test Events instead of production. Normal events are never affected.
5. Under **Trigger events**, check **Game Launch** (`game_launch`), **Purchase** (`purchase`), or both. At least one must be selected.
6. Click **Save**. Hub validates your credentials against the TikTok Ads API before saving; if the token or pixel code is invalid you'll see an error immediately.
### Step 3: Verify delivery
Once a real attributed conversion arrives — a player who clicked your TikTok Ads ad and then converted — you'll see it appear in the **Delivery logs** section on the same page. You can also check **TikTok Events Manager** under your pixel's event activity.
***
## Set Up Reddit Ads Postbacks
### Step 1: Get your Reddit Ads credentials
Reddit Ads uses a **Conversion Access Token (CAT)**, a long-lived non-expiring key designed for server-side API use.
1. **Conversion Access Token**: In [Reddit Ads Manager](https://ads.reddit.com), go to **Events Manager**, your pixel, **Conversions API**, then **Generate Conversion Access Token**. Any administrator for the ad account can generate this.
2. **Pixel ID**: Shown in Reddit Ads Manager next to your pixel (format: `a2_xxxxxxxx`).
The Reddit Ads Conversion Access Token doesn't expire and can be regenerated from Ads Manager at any time without affecting your existing integration until you update it in Hub.
### Step 2: Configure in Hub
1. In [Audience Hub](https://hub.immutable.com), navigate to **Settings** → **Integrations** → **Reddit Ads**.
2. In the **Conversion postbacks** section, click **+ Add postback**.
3. Enter your **Conversion access token** and **Pixel ID**.
4. (Optional) Enter a **Test ID** from Reddit Ads Manager. Hub uses this ID when verifying credentials on save, and routes any events marked `test: true` by the SDK through your test pixel instead of production. Normal events are never affected.
5. Under **Trigger events**, check **Game Launch** (`game_launch`), **Purchase** (`purchase`), or both. At least one must be selected.
6. Click **Save**. Hub validates your credentials against the Reddit Ads API before saving.
Reddit Ads requires either a click ID (from a Reddit Ads campaign click captured by the Tracking Pixel) or a hashed email address to match conversions to users. If neither is present on an attributed event, that conversion won't be forwarded. Make sure your Tracking Pixel is correctly installed on any pages you're running Reddit Ads campaigns against.
### Step 3: Verify delivery
Once a real attributed conversion arrives — a player who clicked your Reddit Ads ad and then converted — you'll see it appear in the **Delivery logs** section on the same page. You can also check **Reddit Ads Manager** under Events Manager → your pixel's activity.
***
## Event Types
Fires when a player first successfully launches the game: the `game_launch` event sent by the Unity SDK on initialisation. This is the primary acquisition signal; it tells the ad network that a click led to an actual install and run.
* **TikTok Ads:** maps to `CompleteRegistration`
* **Reddit Ads:** maps to a custom event named `game_launch`
Fires when a player completes a purchase: the `purchase` event with optional `value` and `currency` fields. When value and currency are present, Immutable forwards them so ad networks can optimise for revenue (ROAS), not just conversion count.
* **TikTok Ads:** maps to `Purchase`
* **Reddit Ads:** maps to `Purchase`
You can enable one or both event types per network. Most studios enable both for maximum signal quality.
***
## Monitoring Postback Delivery
The **Delivery logs** section on the TikTok Ads or Reddit Ads integration page (**Settings** → **Integrations** → **\[Network]**) shows every postback dispatch attempt for that game.
Filter by time range (last hour, 24 hours, 7 days, or 30 days) and status. Each log entry shows:
| Column | Description |
| ----------- | --------------------------------------------------------------- |
| **Time** | When the postback was sent (hover for the exact timestamp) |
| **Event** | The conversion event that triggered it: Game Launch or Purchase |
| **Status** | Delivery outcome (see below) |
| **HTTP** | The HTTP status code returned by the ad network |
| **Latency** | Round-trip time in milliseconds |
| **Attempt** | Which retry attempt this entry represents |
The network accepted the postback
The network rejected the payload — usually a credential or configuration issue; not retried
A transient network error; Immutable automatically retried the delivery
### Health status
Each postback config in the list shows a health indicator alongside its enabled/disabled badge:
| Indicator | Meaning |
| --------------------------------------------- | --------------------------------------------------------------------------------------------- |
| No indicator | Postbacks are delivering normally |
| Warning icon — "Credential failures detected" | Recent deliveries are failing with auth errors — credentials may have been revoked or expired |
| Clock icon — "No recent activity" | No delivery history yet; normal for a newly configured postback |
If you see the credential failures warning, open the config menu → **Edit** → expand **Rotate credentials** → enter fresh credentials → **Save**.
***
## FAQ
No. Postbacks rely on click IDs (`ttclid` from TikTok Ads, click IDs from Reddit Ads campaigns) captured by the [Tracking Pixel](/docs/products/audience/tracking-pixel) or [Web SDK](/docs/products/audience/web-sdk) on your marketing site when a player arrives from an ad. Without a click ID, Immutable cannot tie the in-game conversion back to the original ad click, and there is nothing to forward to the ad network.
No. Only conversions attributed to a paid ad click on a configured network are forwarded. Organic players, players from untracked sources, and players whose click happened outside the supported networks do not generate postbacks.
Yes. The [REST API](/docs/products/audience/rest-api) accepts the same `game_launch` and `purchase` events. Events from any source (Unity SDK, Web SDK, REST API) are eligible to trigger postbacks once attributed.
Postbacks only fire for events that were collected at `anonymous` or `full` consent. Events at `none` are not collected so cannot be attributed or forwarded. See the [Data Dictionary](/docs/products/audience/data-dictionary#consent-model) for the full consent model.
Events marked `test: true` (from SDK or Pixel `testMode`) are not forwarded by default. If a **Test event code** (TikTok) or **Test ID** (Reddit) is saved on the postback credentials, test events are routed to that network's test environment instead. Normal production events are never affected by the test code, even when one is set.
Erasure removes the user's data from Immutable's systems (see [Deleting User Data](/docs/products/audience/rest-api#deleting-user-data)). Postbacks already delivered to ad networks are not retracted by Immutable; once the network has received the conversion signal, it operates under that network's data retention policy. To request erasure from the ad network itself, contact TikTok Ads or Reddit Ads directly. New postbacks will not be sent for the erased user.
The validation step calls the ad network's API with a synthetic test event. Common causes:
* **TikTok Ads:** the token was generated from the wrong pixel, or the user who generated it doesn't have admin rights on that pixel. Try regenerating from the correct pixel.
* **Reddit Ads:** the Pixel ID format should be `a2_xxxxxxxx`. Check for leading/trailing spaces.
* For both networks: verify the token and pixel ID are copied without truncation.
Check the following:
* Is the postback config **enabled**?
* Are the trigger events configured (Game Launch, Purchase, or both)?
* Is the Immutable Tracking Pixel installed and capturing click IDs on the pages you're running ads against? Without a click ID, Immutable can't attribute the conversion to a paid ad click.
* Is the Unity SDK, Web SDK, or REST API sending the expected events?
The ad network rejected the payload. Check the HTTP status code shown in the log entry:
* **401 / 403:** your credentials have expired or been revoked. Rotate them in Hub.
* **400:** the event payload was rejected by the network's validation. Contact your Immutable account manager with the attribution event ID from the log.
Additional networks are not yet available for self-service configuration. Contact your Immutable account manager or reach out via the [support portal](https://support.immutable.com) to discuss your needs. We'll update this documentation as more networks become available.
## Next Steps
How tracking data powers player attribution and Hub reports
Full reference of event schemas and consent levels
Make sure click IDs are being captured from your ad platforms
Typed SDK for web games, marketing sites, and SPAs
In-game tracking for Unity desktop builds
Send events from your backend or game server
# Data Dictionary
Source: https://docs.immutable.com/docs/products/audience/data-dictionary
Complete reference of data collected and event schemas across the Tracking Pixel, Web SDK, Unity SDK, and REST API
The Tracking Pixel, Web SDK, Unity SDK, and REST API are currently in **alpha**. The data collected and event schemas may change between releases.
Full reference of data collected and event schemas for the [Tracking Pixel](/docs/products/audience/tracking-pixel), [Web SDK](/docs/products/audience/web-sdk), [Unity SDK](/docs/products/audience/unity-sdk), and [REST API](/docs/products/audience/rest-api), and the data forwarded externally by [Conversion Postbacks](/docs/products/audience/conversion-postbacks). Use it to understand what each integration sends, when it sends it, and what properties each event carries. Also useful for privacy reviews, legal assessments, and technical audits.
## Cookies
First-party cookies only. No third-party cookies are created.
The Unity SDK does not use cookies. It persists `AnonymousId` and queued events to native local storage instead.
imtbl_anon_id Tracking PixelWeb SDK>}>
Persistent anonymous device identifier (UUID v4). Shared between the Tracking Pixel and Web SDK.
| Property | Value |
| ---------- | --------------------------------- |
| Lifetime | 2 years |
| Scope | First-party, current hostname |
| Attributes | `SameSite=Lax`, `Secure` on HTTPS |
_imtbl_sid Tracking PixelWeb SDK>}>
Session continuity across page loads. Refreshed on each tracking call. Expires after 30 minutes of inactivity.
| Property | Value |
| ---------- | --------------------------------- |
| Lifetime | 30 minutes (rolling) |
| Scope | First-party, current hostname |
| Attributes | `SameSite=Lax`, `Secure` on HTTPS |
_ga Tracking Pixel>}>
Google Analytics client ID. Read-only. The Tracking Pixel reads this cookie for cross-platform identity stitching but does not write it.
| Property | Value |
| -------- | ---------------- |
| Source | Google Analytics |
_fbc Tracking Pixel>}>
Facebook click ID. Read-only. The Tracking Pixel reads this cookie for cross-platform identity stitching but does not write it.
| Property | Value |
| -------- | --------------- |
| Source | Meta (Facebook) |
_fbp Tracking Pixel>}>
Facebook browser ID. Read-only. The Tracking Pixel reads this cookie for cross-platform identity stitching but does not write it.
| Property | Value |
| -------- | --------------- |
| Source | Meta (Facebook) |
## Device Fingerprint Signals
These signals are collected automatically when consent is `anonymous` or `full`.
The Tracking Pixel and Web SDK collect the same browser-based set, so the per-integration tabs for those two repeat the same table.
| Signal | Source | Example |
| ----------------- | -------------------------------------------------- | ---------------------------------------------------- |
| User agent | `navigator.userAgent` | `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...` |
| Screen resolution | `screen.width` × `screen.height` | `1920×1080` |
| Timezone | `Intl.DateTimeFormat().resolvedOptions().timeZone` | `America/New_York` |
| Browser language | `navigator.language` | `en-US` |
| IP address | Server-side from request headers | Raw IP stored for geo enrichment |
| Signal | Source | Example |
| ----------------- | -------------------------------------------------- | ---------------------------------------------------- |
| User agent | `navigator.userAgent` | `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...` |
| Screen resolution | `screen.width` × `screen.height` | `1920×1080` |
| Timezone | `Intl.DateTimeFormat().resolvedOptions().timeZone` | `America/New_York` |
| Browser language | `navigator.language` | `en-US` |
| IP address | Server-side from request headers | Raw IP stored for geo enrichment |
The Unity SDK collects a different fingerprint set (CPU, GPU, RAM, OS family, Unity version, screen DPI, device model) and emits it once per session as part of [`game_launch`](#auto-tracked-events) rather than on every event.
## Attribution Signals
The Tracking Pixel and Web SDK collect the same URL-based signals. Mobile Unity builds with attribution enabled collect a separate set of device-level signals. Desktop Unity builds collect none.
The Tracking Pixel collects these on every page load.
| Signal | Source | Example |
| ---------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| UTM parameters | URL query string | `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` |
| Google click ID | URL query string | `gclid` |
| Meta click ID | URL query string | `fbclid` |
| TikTok click ID | URL query string | `ttclid` |
| Microsoft click ID | URL query string | `msclkid` |
| Display & Video 360 click ID | URL query string | `dclid` |
| LinkedIn click ID | URL query string | `li_fat_id` |
| Referrer | `document.referrer` | The referring page URL |
| Landing page | `window.location.href` (first page in session) | Entry point URL |
| Referral code | URL query string | `referral_code`: a custom parameter you add to campaign links (e.g. `?referral_code=influencer-abc`) to track referral sources |
The Web SDK attaches them to `session_start`, the first `page()` call, and the `sign_up` and `link_clicked` events.
| Signal | Source | Example |
| ---------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| UTM parameters | URL query string | `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` |
| Google click ID | URL query string | `gclid` |
| Meta click ID | URL query string | `fbclid` |
| TikTok click ID | URL query string | `ttclid` |
| Microsoft click ID | URL query string | `msclkid` |
| Display & Video 360 click ID | URL query string | `dclid` |
| LinkedIn click ID | URL query string | `li_fat_id` |
| Referrer | `document.referrer` | The referring page URL |
| Landing page | `window.location.href` (first page in session) | Entry point URL |
| Referral code | URL query string | `referral_code`: a custom parameter you add to campaign links (e.g. `?referral_code=influencer-abc`) to track referral sources |
Desktop builds do not collect URL-based attribution signals. The closest analogue is the studio-supplied `distributionPlatform` property on [`game_launch`](#auto-tracked-events).
Mobile [attribution builds](/docs/products/audience/unity-sdk#mobile) (`AUDIENCE_MOBILE_ATTRIBUTION` + `EnableMobileAttribution = true`) ship additional signals with `game_launch`.
**iOS**
| Property | Type | Consent | Description |
| ---------------- | --------- | ------------ | -------------------------------------------------------------------------------------------------------- |
| `attStatus` | `string` | `Anonymous`+ | ATT authorization status: `notDetermined`, `restricted`, `denied`, or `authorized`. |
| `idfa` | `string` | `Full` | Advertising identifier. Present only when ATT status is `authorized` and consent is `Full`. |
| `skanRegistered` | `boolean` | `Anonymous`+ | `true` on the launch where SKAdNetwork first-install registration fires. Omitted on subsequent launches. |
**Android**
| Property | Type | Consent | Description |
| --------------------- | --------- | ------------ | -------------------------------------------------------------------------------------------------------------- |
| `gaid` | `string` | `Full` | Google Advertising ID. Omitted when `gaidLimitAdTracking` is `true`. Available from the second launch onwards. |
| `gaidLimitAdTracking` | `boolean` | `Anonymous`+ | Whether the user has opted out of ad personalization. Available from the second launch onwards. |
## Identity Stitching
Each integration assigns an `anonymousId` automatically when tracking begins. As a player moves through your funnel (visiting your marketing site, creating an account, launching the game), identity calls connect those anonymous sessions to a known player in attribution reports.
| Integration | How `anonymousId` is assigned |
| -------------- | ---------------------------------------------------------------------- |
| Tracking Pixel | Reads or sets the `imtbl_anon_id` first-party cookie |
| Web SDK | Reads or sets the same `imtbl_anon_id` cookie |
| Unity SDK | Generates a persistent GUID stored in `Application.persistentDataPath` |
| REST API | Provided by the caller in the message payload |
The Tracking Pixel and Web SDK share the `imtbl_anon_id` cookie on the same domain, so sessions on the same domain are already continuous before any login occurs.
Call `identify()` at login to associate the player's `userId` with their activity and traits. A player who logs in on both the marketing site and in-game using the same `userId` will have both session histories attributed to the same profile, no `alias` call needed.
Call `alias()` when the same player is known by different provider IDs across surfaces, for example a player previously identified as a Steam user who later links a Passport account.
## Auto-Tracked Events
Events fired by the integration without studio code. Title badges indicate which integrations emit each event. Trigger conditions and per-integration property differences are described inside the accordion.
game_launch Unity SDK>}>
Player launched the game. The Unity SDK fires this automatically at `Init` when consent permits tracking. The Web SDK and REST API do not currently emit this event.
**Event name:** `game_launch`
| Property | Type | Required | Description |
| ---------------------- | --------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `platform` | `string` | No | OS or mobile platform the game is running on. One of `'Windows'`, `'macOS'`, `'Linux'`, `'iOS'`, `'Android'`. |
| `isEditor` | `boolean` | No | `true` when the game is running inside the Unity Editor; `false` for shipped builds. Use this to filter dev runs out of production analytics. |
| `version` | `string` | No | Game version string |
| `buildGuid` | `string` | No | Unity build GUID |
| `unityVersion` | `string` | No | Unity version the build was made with |
| `osFamily` | `string` | No | Host operating system family from Unity's `SystemInfo.operatingSystemFamily`. One of `'Windows'`, `'MacOSX'`, `'Linux'`, `'Other'`. |
| `deviceModel` | `string` | No | Hardware model |
| `gpu` | `string` | No | GPU name |
| `gpuVendor` | `string` | No | GPU vendor |
| `cpu` | `string` | No | CPU name |
| `cpuCores` | `integer` | No | Number of CPU cores |
| `ramMb` | `integer` | No | System RAM in megabytes |
| `screenDpi` | `integer` | No | Display DPI (omitted when 0). |
| `distributionPlatform` | `string` | No | Value of `AudienceConfig.DistributionPlatform` (e.g. `'steam'`, `'epic'`). Omitted if not set. Studio-supplied storefront identifier. Complements the Unity-emitted `platform`, which is the OS-level player runtime. |
| `idfv` | `string` | No | iOS only. Identifier for Vendor, a device ID scoped to your publisher. Reset when all apps from the same publisher are uninstalled. |
| `androidId` | `string` | No | Android only. Android device unique identifier. |
On mobile with `EnableMobileAttribution = true`, additional attribution signals are included. See [Attribution Signals](#attribution-signals) for the full set.
**Postback forwarding:** when [Conversion Postbacks](/docs/products/audience/conversion-postbacks) are configured for the game, attributed `game_launch` events are forwarded to the configured ad network as a registration/install signal. See [Outbound Forwarding](#outbound-forwarding).
Fired automatically by `ImmutableAudience.Init`. Studios do not call this directly. The wire payload includes the full Unity property set:
```json theme={null}
{
"type": "track",
"eventName": "game_launch",
"properties": {
"platform": "Windows",
"isEditor": false,
"version": "1.2.0",
"buildGuid": "00000000-0000-0000-0000-000000000000",
"unityVersion": "2022.3.10f1",
"osFamily": "Windows",
"deviceModel": "PC",
"gpu": "Generic GPU",
"gpuVendor": "Generic",
"cpu": "Generic CPU",
"cpuCores": 8,
"ramMb": 16384,
"screenDpi": 96,
"distributionPlatform": "steam"
}
}
```
session_start Tracking PixelWeb SDKUnity SDK>}>
Fires when a new session begins. Per-integration triggers:
* **Tracking Pixel:** no active `_imtbl_sid` cookie.
* **Web SDK:** no active session cookie, or consent upgrades from `'none'`.
* **Unity SDK:** `Init`, consent upgrade from `None`, or resume after a pause longer than 30 seconds.
| Property | Type | Description |
| ----------- | -------- | ---------------------- |
| `sessionId` | `string` | New session identifier |
The Web SDK also includes all attribution signals (`utm_*`, click IDs, `referrer`, `landing_page`, `referral_code`, `touchpoint_type`) on this event.
session_end Tracking PixelWeb SDKUnity SDK>}>
Fires when a session ends. Only fires when consent is at `Anonymous` or higher.
Per-integration triggers:
* **Tracking Pixel:** page unload.
* **Web SDK:** `shutdown()` is called.
* **Unity SDK:** `Shutdown` is called, consent is downgraded to `None`, or a session rolls due to extended pause.
| Property | Type | Surfaces | Description |
| ------------- | -------- | ----------------------- | --------------------------------------------------------------------------------- |
| `sessionId` | `string` | All | Current session identifier |
| `duration` | `number` | Tracking Pixel, Web SDK | Seconds since `session_start`, wall-clock |
| `durationSec` | `number` | Unity SDK | Engagement seconds since `session_start`, wall-clock minus accumulated pause time |
The Tracking Pixel and Web SDK report `duration` as wall-clock time. The Unity SDK reports `durationSec` as engagement time, excluding pause durations. The field names differ to make the semantic difference explicit. Dashboards aggregating session length across integrations should treat these as separate metrics.
The Tracking Pixel and Web SDK use `fetch` with `keepalive: true` to ensure delivery on page unload.
session_heartbeat Unity SDK>}>
Fires every 60 seconds while the game is focused. Pause and resume reset the heartbeat clock, so wall-clock and reported engagement time can diverge.
| Property | Type | Description |
| ------------- | -------- | --------------------------------------------------------------------------------- |
| `sessionId` | `string` | Current session identifier |
| `durationSec` | `number` | Engagement seconds since `session_start`, wall-clock minus accumulated pause time |
Used to estimate playtime per session for studios where players regularly leave the game backgrounded. The Web SDK and Tracking Pixel do not emit this event.
tracking_authorization_changed Unity SDK>}>
ATT authorization status changed from its previously recorded value. Does not fire on first launch. The initial state is reported by `game_launch`.
**Requires:** `EnableMobileAttribution = true`, `Anonymous` or `Full` consent, iOS only.
**Event name:** `tracking_authorization_changed`
| Property | Type | Consent | Description |
| ---------------- | -------- | ------------ | -------------------------------------------------------------------------- |
| `previousStatus` | `string` | `Anonymous`+ | Prior ATT status: `notDetermined`, `restricted`, `denied`, or `authorized` |
| `newStatus` | `string` | `Anonymous`+ | New ATT status |
| `idfa` | `string` | `Full` | Present only when transitioning to `authorized` with `Full` consent |
install_referrer_received Unity SDK>}>
Android Play Install Referrer captured. Fires once per install. On first launch the Play Services fetch is usually still in flight when `game_launch` fires, so the event typically arrives on the second launch.
**Requires:** `EnableMobileAttribution = true`, `Full` consent, Android only.
**Event name:** `install_referrer_received`
| Property | Type | Description |
| ----------------- | -------- | --------------------------------------------------------------------------------------- |
| `installReferrer` | `string` | Raw referrer string from Google Play (e.g. `utm_source=google-play&utm_medium=organic`) |
page Tracking PixelWeb SDK>}>
The Tracking Pixel fires this automatically on every page load. In the Web SDK, call `page()` manually on each route change.
| Property | Type | Tracking Pixel | Web SDK | Description |
| ----------------- | -------- | -------------- | ---------------- | ------------------------------------------------------- |
| `sessionId` | `string` | Every page | Every `page()` | Current session identifier |
| `utm_source` | `string` | Every page | Once per session | UTM source parameter from URL |
| `utm_medium` | `string` | Every page | Once per session | UTM medium parameter from URL |
| `utm_campaign` | `string` | Every page | Once per session | UTM campaign parameter from URL |
| `utm_term` | `string` | Every page | Once per session | UTM term parameter from URL |
| `utm_content` | `string` | Every page | Once per session | UTM content parameter from URL |
| `gclid` | `string` | Every page | Once per session | Google Ads click ID |
| `fbclid` | `string` | Every page | Once per session | Meta click ID |
| `ttclid` | `string` | Every page | Once per session | TikTok click ID |
| `msclkid` | `string` | Every page | Once per session | Microsoft click ID |
| `dclid` | `string` | Every page | Once per session | Display & Video 360 click ID |
| `li_fat_id` | `string` | Every page | Once per session | LinkedIn click ID |
| `referrer` | `string` | Every page | Once per session | Referring page URL |
| `landing_page` | `string` | Every page | Once per session | Entry point URL |
| `referral_code` | `string` | Every page | Once per session | Custom referral link parameter |
| `touchpoint_type` | `string` | Every page | Once per session | `'click'` when any click ID or UTM parameter is present |
| `gaClientId` | `string` | Every page | - | Google Analytics client ID (from `_ga` cookie) |
| `fbClickId` | `string` | Every page | - | Facebook click ID (from `_fbc` cookie) |
| `fbBrowserId` | `string` | Every page | - | Facebook browser ID (from `_fbp` cookie) |
form_submitted Tracking Pixel>}>
Fires on HTML form submission. Can be disabled with `autocapture.forms: false`.
| Property | Type | Description |
| ------------ | ---------- | -------------------------------------------------- |
| `formAction` | `string` | Form action URL |
| `formId` | `string` | Form element ID |
| `formName` | `string` | Form element name |
| `fieldNames` | `string[]` | Names of form fields |
| `emailHash` | `string` | SHA-256 hashed email address (`full` consent only) |
scroll_depth Tracking Pixel>}>
Fires when the user scrolls past a depth milestone (25%, 50%, 75%, 90%, 100%). Each milestone fires at most once per page load. On above-the-fold pages (all content visible without scrolling), fires `depth: 100` with `aboveFold: true` after a 2-second dwell time to filter immediate bounces. Can be disabled with `autocapture.scroll: false`.
| Property | Type | Description |
| ----------- | --------- | ------------------------------------------------------------- |
| `depth` | `integer` | Milestone reached: 25, 50, 75, 90, or 100 |
| `aboveFold` | `boolean` | `true` on above-the-fold pages (only present when applicable) |
| `sessionId` | `string` | Current session identifier |
link_clicked (auto) Tracking Pixel>}>
Fires on outbound link clicks (external domains only). Can be disabled with `autocapture.clicks: false`.
| Property | Type | Description |
| ----------- | --------- | ----------------- |
| `linkUrl` | `string` | Destination URL |
| `linkText` | `string` | Link display text |
| `elementId` | `string` | Link element ID |
| `outbound` | `boolean` | Always `true` |
## Predefined Events
Typed events for common player actions. These schemas apply to the [Web SDK](/docs/products/audience/web-sdk), the [Unity SDK](/docs/products/audience/unity-sdk), and the [REST API](/docs/products/audience/rest-api). Each event accordion below shows the typed call shape for every integration.
Properties are identical across integrations unless noted. Each integration also auto-attaches its own metadata to every event. See [Auto-attached metadata](#auto-attached-metadata) below.
### Event schemas
sign_up Web SDKREST API>}>
Player created a new account.
**Event name:** `sign_up`
| Property | Type | Required | Description |
| -------- | -------- | -------- | ------------------------------------------------------------------- |
| `method` | `string` | No | How the player signed up (e.g. `'email'`, `'google'`, `'passport'`) |
```typescript theme={null}
audience.track(AudienceEvents.SIGN_UP, { method: 'email' });
```
```json theme={null}
{
"type": "track",
"eventName": "sign_up",
"properties": { "method": "email" }
}
```
sign_in Web SDKREST API>}>
Player signed in to an existing account.
**Event name:** `sign_in`
| Property | Type | Required | Description |
| -------- | -------- | -------- | --------------------------------------------------------------------- |
| `method` | `string` | No | Authentication method used (e.g. `'email'`, `'google'`, `'passport'`) |
```typescript theme={null}
audience.track(AudienceEvents.SIGN_IN, { method: 'google' });
```
```json theme={null}
{
"type": "track",
"eventName": "sign_in",
"properties": { "method": "google" }
}
```
purchase Web SDKUnity SDKREST API>}>
Player completed a purchase.
**Event name:** `purchase`
| Property | Type | Required | Description |
| --------------- | -------- | -------- | ------------------------------------- |
| `currency` | `string` | **Yes** | Currency code (e.g. `'USD'`, `'ETH'`) |
| `value` | `number` | **Yes** | Total purchase value |
| `itemId` | `string` | No | Unique identifier for the item |
| `itemName` | `string` | No | Display name of the item |
| `quantity` | `number` | No | Number of items purchased |
| `transactionId` | `string` | No | Your internal transaction reference |
**Postback forwarding:** when [Conversion Postbacks](/docs/products/audience/conversion-postbacks) are configured for the game, attributed `purchase` events (including `value` and `currency`) are forwarded to the configured ad network for ROAS optimisation. See [Outbound Forwarding](#outbound-forwarding).
Player buys a sword for \$9.99:
```typescript theme={null}
audience.track(AudienceEvents.PURCHASE, {
currency: 'USD',
value: 9.99,
itemName: 'Legendary Sword',
quantity: 1,
});
```
```csharp theme={null}
ImmutableAudience.Track(new Purchase
{
Currency = "USD",
Value = 9.99m,
ItemName = "Legendary Sword",
Quantity = 1,
});
```
```json theme={null}
{
"type": "track",
"eventName": "purchase",
"properties": {
"currency": "USD",
"value": 9.99,
"itemName": "Legendary Sword",
"quantity": 1
}
}
```
progression Web SDKUnity SDKREST API>}>
Player started, completed, or failed a level or stage.
**Event name:** `progression`
| Property | Type | Required | Description |
| ------------- | --------------------------------- | -------- | ---------------------------------------------------------------- |
| `status` | `'start' \| 'complete' \| 'fail'` | **Yes** | Whether the player started, completed, or failed this segment |
| `world` | `string` | No | Top-level grouping (e.g. `'forest'`, `'dungeon'`, `'chapter-1'`) |
| `level` | `string` | No | Level within the world (e.g. `'3'`, `'boss'`) |
| `stage` | `string` | No | Sub-level or checkpoint within the level |
| `score` | `number` | No | Score achieved |
| `durationSec` | `number` | No | Time spent in seconds |
Player completed level 3 of the forest world with a score of 4500 in 2 minutes:
```typescript theme={null}
audience.track(AudienceEvents.PROGRESSION, {
status: 'complete',
world: 'forest',
level: '3',
score: 4500,
durationSec: 120,
});
```
```csharp theme={null}
ImmutableAudience.Track(new Progression
{
Status = ProgressionStatus.Complete,
World = "forest",
Level = "3",
Score = 4500,
DurationSec = 120,
});
```
```json theme={null}
{
"type": "track",
"eventName": "progression",
"properties": {
"status": "complete",
"world": "forest",
"level": "3",
"score": 4500,
"durationSec": 120
}
}
```
resource Web SDKUnity SDKREST API>}>
Player gained or spent an in-game resource.
**Event name:** `resource`
| Property | Type | Required | Description |
| ---------- | -------------------- | -------- | ------------------------------------------------------------------------- |
| `flow` | `'sink' \| 'source'` | **Yes** | `'sink'` when the player spends a resource, `'source'` when they gain one |
| `currency` | `string` | **Yes** | The resource type (e.g. `'gold'`, `'gems'`, `'energy'`) |
| `amount` | `number` | **Yes** | Quantity gained or spent |
| `itemType` | `string` | No | Category of the item involved (e.g. `'weapon'`, `'consumable'`) |
| `itemId` | `string` | No | Unique identifier for the item |
Player spends 500 gold on a weapon:
```typescript theme={null}
audience.track(AudienceEvents.RESOURCE, {
flow: 'sink',
currency: 'gold',
amount: 500,
itemType: 'weapon',
itemId: 'legendary-sword',
});
```
```csharp theme={null}
ImmutableAudience.Track(new Resource
{
Flow = ResourceFlow.Sink,
Currency = "gold",
Amount = 500,
ItemType = "weapon",
ItemId = "legendary-sword",
});
```
```json theme={null}
{
"type": "track",
"eventName": "resource",
"properties": {
"flow": "sink",
"currency": "gold",
"amount": 500,
"itemType": "weapon",
"itemId": "legendary-sword"
}
}
```
wishlist_add / wishlist_remove Web SDKREST API>}>
Player added or removed a game from their wishlist.
**Event names:** `wishlist_add`, `wishlist_remove`
**wishlist\_add:**
| Property | Type | Required | Description |
| ---------- | -------- | -------- | -------------------------------------------------------------------------- |
| `gameId` | `string` | **Yes** | Unique identifier for the game |
| `source` | `string` | No | Where the action happened (e.g. `'store'`, `'search'`, `'recommendation'`) |
| `platform` | `string` | No | Platform (e.g. `'steam'`, `'epic'`) |
```typescript theme={null}
audience.track(AudienceEvents.WISHLIST_ADD, {
gameId: 'game-123',
source: 'store',
platform: 'steam',
});
```
```json theme={null}
{
"type": "track",
"eventName": "wishlist_add",
"properties": { "gameId": "game-123", "source": "store", "platform": "steam" }
}
```
**wishlist\_remove:**
| Property | Type | Required | Description |
| -------- | -------- | -------- | ------------------------------ |
| `gameId` | `string` | **Yes** | Unique identifier for the game |
```typescript theme={null}
audience.track(AudienceEvents.WISHLIST_REMOVE, { gameId: 'game-123' });
```
```json theme={null}
{
"type": "track",
"eventName": "wishlist_remove",
"properties": { "gameId": "game-123" }
}
```
email_acquired Web SDKREST API>}>
Captured a player's email address (e.g. from a newsletter signup or waitlist form).
**Event name:** `email_acquired`
| Property | Type | Required | Description |
| -------- | -------- | -------- | ------------------------------------------------------------------------------- |
| `source` | `string` | No | Where the email was collected (e.g. `'waitlist'`, `'newsletter'`, `'checkout'`) |
```typescript theme={null}
audience.track(AudienceEvents.EMAIL_ACQUIRED, { source: 'waitlist' });
```
```json theme={null}
{
"type": "track",
"eventName": "email_acquired",
"properties": { "source": "waitlist" }
}
```
game_page_viewed Web SDKREST API>}>
Player viewed a game page.
**Event name:** `game_page_viewed`
| Property | Type | Required | Description |
| ---------- | -------- | -------- | ------------------------------------------ |
| `gameId` | `string` | **Yes** | Unique identifier for the game |
| `gameName` | `string` | No | Display name of the game |
| `slug` | `string` | No | URL-friendly identifier (e.g. `'my-game'`) |
```typescript theme={null}
audience.track(AudienceEvents.GAME_PAGE_VIEWED, {
gameId: 'game-123',
gameName: 'My Game',
slug: 'my-game',
});
```
```json theme={null}
{
"type": "track",
"eventName": "game_page_viewed",
"properties": { "gameId": "game-123", "gameName": "My Game", "slug": "my-game" }
}
```
link_clicked Web SDKREST API>}>
Player clicked a tracked link.
**Event name:** `link_clicked`
| Property | Type | Required | Description |
| -------- | -------- | -------- | --------------------------------------------------------------------- |
| `url` | `string` | **Yes** | Destination URL |
| `label` | `string` | No | Display text or label for the link |
| `source` | `string` | No | Where the link appeared (e.g. `'navbar'`, `'footer'`, `'cta-banner'`) |
| `gameId` | `string` | No | Associated game identifier, if applicable |
```typescript theme={null}
audience.track(AudienceEvents.LINK_CLICKED, {
url: 'https://store.steampowered.com/app/123',
label: 'Wishlist on Steam',
source: 'cta-banner',
});
```
```json theme={null}
{
"type": "track",
"eventName": "link_clicked",
"properties": {
"url": "https://store.steampowered.com/app/123",
"label": "Wishlist on Steam",
"source": "cta-banner"
}
}
```
milestone_reached Unity SDK>}>
Player reached a named milestone or achievement. Use for one-shot accomplishments that do not fit the start/complete/fail shape of `progression`.
**Event name:** `milestone_reached`
| Property | Type | Required | Description |
| -------- | -------- | -------- | -------------------------------------------------------------------------- |
| `name` | `string` | **Yes** | Milestone identifier (e.g. `'first_boss_defeated'`, `'tutorial_complete'`) |
Player defeats the first boss:
```csharp theme={null}
ImmutableAudience.Track(new MilestoneReached { Name = "first_boss_defeated" });
```
The Web SDK and REST API do not yet ship a typed equivalent. Studios on those surfaces can emit the same event by passing the event name directly.
### Auto-attached metadata
The Web SDK and Unity SDK auto-attach a shared baseline to every event:
* A persistent anonymous device identifier (`anonymousId`)
* An integration tag identifying the SDK source
* The library identifier and version
* Locale, screen size, timezone, and user-agent string
The Web SDK additionally captures browser page context (current page URL, path, referrer, and title) on every event. The tabs below document only the integration-specific additions on top of that baseline.
The backend additionally stamps `received_at` and IP-derived geo on every event regardless of integration.
| Method call | Auto-attached |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| every `track()` call | `sessionId` |
| `track('sign_up', ...)`, `track('link_clicked', ...)` | `sessionId` plus [attribution signals](#attribution-signals) from the current page URL |
| every `track()` call after `identify()` (Full consent only) | `userId` |
Attribution values come from the page where the event fires, not the player's landing page. A player who arrives via `?utm_source=discord`, navigates to another page, then triggers `sign_up` will have no `utm_source` on that event. `landing_page` is not attached.
| Method call | Auto-attached |
| ------------------------------------------------------- | ------------- |
| every `Track` call after `Identify` (Full consent only) | `userId` |
The shared baseline `context` is computed once at `Init` and attached unchanged to every event, rather than reassembled per call.
## Consent Model
All Audience integrations use a three-tier consent model. Consent defaults to None and can be changed at any time. Changes take effect immediately. The Audience integrations do not provide a consent UI. You are responsible for building the cookie banner or privacy prompt and setting the consent level when the user makes a choice.
| Level | What it does | When to set |
| ------------- | ----------------------------------------- | ------------------------------------------------- |
| **None** | Loads but collects nothing | Before consent is given or when consent is denied |
| **Anonymous** | Tracks activity without user identity | After analytics consent |
| **Full** | Tracks everything including user identity | After full tracking consent |
Native games do not have a cookie banner. Studios typically gate the consent call on a first-launch privacy prompt or a settings menu.
### Downgrading Consent
You can downgrade consent at any time. All integrations share this core behavior:
* **Full → Anonymous:** strips player identity from queued events, removes pending `identify()` and `alias()` messages.
* **Any level → None:** purges all queued events.
Clears the session cookie. The anonymous device cookie persists per its lifetime unless the user clears cookies in the browser.
Clears local state (cookies and identity) so no tracking artifacts remain on the device.
Fires `session_end` on downgrade to `None`, then stops emitting until consent is restored. `AnonymousId` persists in native local storage across downgrades so a later upgrade resumes the same anonymous identity rather than minting a fresh one.
Consent state is the caller's responsibility. The API has no client-side state to clear on downgrade.
The tables below show whether each data category is collected at each consent level.
| Data | `'none'` | `'anonymous'` | `'full'` |
| ------------------------ | --------------- | ----------------------------- | ----------------------------- |
| Cookies | `imtbl_anon_id` | `imtbl_anon_id`, `_imtbl_sid` | `imtbl_anon_id`, `_imtbl_sid` |
| Page views | No | Yes | Yes |
| Session events | No | Yes | Yes |
| Device fingerprint | No | Yes | Yes |
| Attribution signals | No | Yes | Yes |
| Outbound link clicks | No | Yes | Yes |
| Form submissions | No | Yes (no email) | Yes (hashed email) |
| Scroll depth milestones | No | Yes | Yes |
| Third-party ID stitching | No | Yes | Yes |
| Data | `'none'` | `'anonymous'` | `'full'` |
| ------------------- | -------- | ----------------------------- | ----------------------------- |
| Cookies | None | `imtbl_anon_id`, `_imtbl_sid` | `imtbl_anon_id`, `_imtbl_sid` |
| Session events | No | Yes | Yes |
| Device fingerprint | No | Yes | Yes |
| Attribution signals | No | Yes | Yes |
| `page()` | No | Yes | Yes |
| `track()` | No | Yes | Yes |
| `identify()` | No | No | Yes |
| `alias()` | No | No | Yes |
| Data | `None` | `Anonymous` | `Full` |
| -------------------------------------------------------------------- | ------------------ | ---------------------------- | --------------------------------------------------------------------- |
| Local storage | `AnonymousId` only | `AnonymousId`, queued events | `AnonymousId`, queued events |
| Session events (`session_start`, `session_heartbeat`, `session_end`) | No | Yes | Yes |
| `game_launch` (auto at `Init`) | No | Yes | Yes |
| Device fingerprint (CPU, GPU, RAM, OS, Unity version) | No | Yes (in `game_launch`) | Yes (in `game_launch`) |
| Custom `Track` calls | No | Yes | Yes |
| `Identify` (player identity) | No | No | Yes |
| IDFV, Android device ID (all mobile builds) | No | Yes (in `game_launch`) | Yes (in `game_launch`) |
| ATT status, SKAdNetwork registration (iOS attribution builds) | No | Yes (in `game_launch`) | Yes (in `game_launch`) |
| IDFA (iOS, attribution builds) | No | No | Yes, when ATT `authorized` |
| GAID, Install Referrer (Android, attribution builds) | No | No | Yes, from second launch (unless user opted out of ad personalization) |
Consent is your responsibility to enforce. The API processes only what you explicitly send. See [REST API: Consent](/docs/products/audience/rest-api#consent) for the consent endpoints.
## Outbound Forwarding
Most data collected by Audience integrations stays within Immutable. The exception is when [Conversion Postbacks](/docs/products/audience/conversion-postbacks) are configured for a game: attributed `game_launch` and `purchase` events are forwarded server-to-server to the configured ad network (TikTok Ads, Reddit Ads).
Forwarded payloads contain only the fields each ad network requires for conversion matching:
| Forwarded field | Source |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Click ID (`ttclid` for TikTok Ads, click ID for Reddit Ads) | Captured by the Tracking Pixel or Web SDK from the URL when a player arrived from a paid ad |
| Event name (mapped to the network's taxonomy) | The originating `game_launch` or `purchase` event |
| `value` and `currency` | The `purchase` event's `value` and `currency` properties |
| Hashed email (Reddit Ads only, when no click ID is present) | The player's `identify()` email, hashed with SHA-256 |
Player names, raw emails, wallet addresses, device fingerprints, session details, and event properties beyond the table above are **not** forwarded. Only events from players at `anonymous` or `full` consent are eligible. Forwarding requires explicit per-game configuration in [Audience Hub](https://hub.immutable.com); no postbacks fire for games without an active configuration.
See [Conversion Postbacks](/docs/products/audience/conversion-postbacks) for setup, supported networks, and delivery monitoring.
## What Is Not Collected
None of the Audience integrations collect the following:
* No cross-domain tracking (first-party cookies only)
* No session replay or screen recording
* No heatmaps or mouse movement tracking
* No A/B testing or feature flags
* No impression or view-through tracking (click-through only)
* No raw email addresses (only SHA-256 hashed, only at `full` consent)
Additionally, the Tracking Pixel does not support custom event tracking. Use the [Web SDK](/docs/products/audience/web-sdk), [Unity SDK](/docs/products/audience/unity-sdk), or [REST API](/docs/products/audience/rest-api) for that.
## Next Steps
How tracking data powers player attribution and Hub reports
Passive tracking snippet for marketing sites and landing pages
Typed SDK for web games, marketing sites, and SPAs
In-game tracking for Unity games on PC and mobile.
Send events from your backend or game server
Send attributed conversions back to ad networks to improve campaign optimisation
# Engagement
Source: https://docs.immutable.com/docs/products/audience/engagement
Most wishlists don't convert because players go cold between signup and launch. Engagement keeps your audience warm with automated, personalized communication across every channel.
## The Conversion Problem
Traditional pre-launch:
```
Player signs up → Silence for 6 months → Launch email → 4% open rate → 0.5% conversion
```
With Engagement:
```
Player signs up → Onboarding sequence → Weekly updates → [Quest](/docs/products/audience/questing) notifications →
Personalized reminders → Launch countdown → 40% open rate → 15% conversion
```
## Lifecycle Marketing
Automated campaigns that run on autopilot:
Introduce your game, set expectations, drive first engagement
Regular updates, behind-the-scenes content, community highlights
Win back players who've gone quiet with personalized messaging
Build excitement with milestone emails and exclusive previews
High-urgency conversion push to your warmest audience
## Channels
Reach players wherever they are:
Primary channel for detailed updates and conversions
Instant notifications and community updates
In-app and browser notifications for time-sensitive content
## Smart Features
We coordinate across all Immutable games—20 different games won't spam the same user in the same week.
Messages delivered when each player is most likely to engage, based on historical behavior.
Different messages for whales vs. casual players, competitive vs. social gamers.
Subject lines, content, and offers personalized per-user using AI.
## Campaign Types
### Automated (Default)
Best-practice campaigns that run without configuration:
| Campaign | Trigger | Goal |
| ------------- | ---------------- | ---------------------------- |
| Welcome | Signup | Drive first quest completion |
| Weekly Digest | Time-based | Keep audience warm |
| Milestone | Achievement | Celebrate and encourage |
| Win-back | 14 days inactive | Re-engage lapsed users |
| Launch | Launch date | Convert to players |
### Custom Campaigns
Build your own campaigns with full control:
```yaml theme={null}
name: Alpha Access Announcement
segment: tier_3_players
channels: [email, telegram, push]
content:
subject: "You're in! Alpha access starting tomorrow"
body: "As a Tier 3 supporter, you get exclusive early access..."
schedule: 2024-03-15 18:00 UTC
```
## Player Health Scores
Track engagement across your audience:
| Score | Status | Action |
| ------ | ---------- | ---------------------------------------------- |
| 80-100 | 🟢 Hot | Maintain engagement, preview exclusive content |
| 50-79 | 🟡 Warm | Increase touchpoints, add incentives |
| 20-49 | 🟠 Cooling | Win-back campaign, special offers |
| 0-19 | 🔴 Cold | Aggressive re-engagement or deprioritize |
## Social Sentiment
Understand what your audience thinks:
Monitor positive/negative sentiment across Discord, Twitter, Telegram
AI recommendations for what to post based on community mood
Score your social channels on content, frequency, and offers
Automated summary of what your audience is saying
## Metrics
Track engagement performance:
| Metric | Description | Benchmark |
| -------------------- | ------------------------ | --------- |
| **Open Rate** | Email opens / sent | 30-40% |
| **Click Rate** | Clicks / opens | 15-25% |
| **Quest Completion** | Quests done / started | 60-70% |
| **Return Rate** | Users returning weekly | 40-50% |
| **Conversion Rate** | Signups → Launch players | 10-20% |
## Integration
Engagement works automatically with your [Game Page](/docs/products/audience/game-page):
```
Player completes quest → Achievement email triggered
Player goes inactive → Win-back sequence starts
Player refers friend → Referral reward notification
Launch approaching → Countdown sequence activates
```
Set up lifecycle marketing in Hub
## Next Steps
Explore all Audience features
Set up your player landing page
Create quests that trigger campaigns
Understand player segments
# Game Page
Source: https://docs.immutable.com/docs/products/audience/game-page
Your Game Page is a dedicated landing page on [Immutable Play](https://play.immutable.com) that wraps your existing channels (Discord, Steam, App Store) into a high-conversion pre-launch surface.
See a live Game Page in action
## Why Use a Game Page?
Traditional pre-launch destinations (Steam store page, Discord server, your website) leave users anonymous, cold, and uncontactable. Your Game Page solves this:
| Traditional Approach | Game Page |
| ----------------------- | ------------------------------------- |
| Anonymous wishlists | Known players with profiles |
| No way to contact users | Email, Telegram, in-app messaging |
| Single touchpoint | Multi-channel engagement |
| Static content | Dynamic quests, rewards, leaderboards |
| No referral mechanics | Built-in viral loops |
## Features
Reward players for following, engaging, and referring friends. Each tier unlocks exclusive content.
Keep players engaged with social tasks, referrals, and community challenges. See [Questing](/docs/products/audience/questing).
Gamify your waitlist with competitive rankings and exclusive rewards for top fans.
Connect your Discord, Twitter, Telegram—reach players wherever they are.
## How It Works
Send all your marketing traffic to your Game Page instead of Steam or your website
Players create accounts, complete quests, earn rewards, and climb leaderboards
We build rich player profiles using linked accounts, activity, and behavior
Referral mechanics, retargeting, and multi-channel engagement grow your base
Convert engaged, contactable players into Day 1 active users
## Reward Tiers
Structure your pre-launch community with progressive rewards:
| Tier | Unlock Condition | Example Rewards |
| ---------- | ------------------------- | ---------------------------------- |
| **Tier 1** | Follow on Play | First reward crate, basic cosmetic |
| **Tier 2** | Complete engagement tasks | Exclusive items, early access |
| **Tier 3** | Top community member | VIP access, founder rewards |
## Page Components
Your Game Page includes:
### Hero Section
* Game logo and branding
* Trailer embed
* Release date countdown
* Platform availability (Steam, App Store, Play Store)
### Art Gallery
* Showcase concept art, screenshots, and promotional images
* Auto-rotating gallery with full-screen view
### Community Section
* Live fan counter
* Reward tier progress
* Quest completion status
* Leaderboard rankings
### Profile System
* Player usernames displayed on leaderboards
* Gem-based customization
* Cross-game identity on Immutable Play
## Acquisition Multiplier
For every user you'd normally acquire to a wishlist, your Game Page multiplies your actual reach:
```
Traditional funnel:
1000 ad clicks → 200 wishlists → 40 Day 1 players (4% conversion)
With Game Page:
1000 ad clicks → 200 signups → 50 referrals → 150 retained → 90 Day 1 players (9% conversion)
```
The multiplier comes from:
* **Funnel optimization**: Higher conversion from click to signup
* **Referral mechanics**: Each user brings friends
* **Retargeting**: Re-engage users who go cold
* **Multi-channel**: Connect across Discord, email, Telegram
## Customization
Configure your page through Hub:
* Upload branding and assets
* Set reward tiers and quests
* Connect social channels
* Customize colors and layout
Work with Immutable's team through [Hub](/docs/products/hub/overview):
* Custom page design
* Campaign strategy
* Quest design
* Analytics and optimization
## Analytics
Track your pre-launch performance:
| Metric | Description |
| --------------------- | ------------------------------- |
| **Signups** | Total players on your Game Page |
| **Conversion Rate** | Visitors → Signups |
| **Referral Rate** | Signups from referrals |
| **Engagement Score** | Quest completion, return visits |
| **Tier Distribution** | Players at each reward tier |
## Get Started
Set up your pre-launch homepage in Hub
Browse live Game Pages on Immutable Play
Game Pages are available to all partners building on Immutable. Best-practice templates run on autopilot, or customize every element.
## Next Steps
Set up quests for your Game Page
Configure lifecycle marketing campaigns
# Audience
Source: https://docs.immutable.com/docs/products/audience/overview
Turn cold, anonymous waitlists into hot launches
Contact us for a free demo and onboarding
**96% of games launched on Steam never reach \$1M in lifetime revenue.** Most fail not because of game quality, but because they can't execute on pre-launch marketing.
Immutable Audience solves the pre-launch growth problem—the #1 reason games fail.
## The Problem
Most pre-launch games concentrate on growing their wishlists or social media followers, pouring hundreds of thousands of dollars into marketing. But unlike post-launch marketing with clear ROI tracking, games are left guessing about the health of their audience, or whether they will convert into players at launch.
No data about users from Steam, App Store, or social platforms. Can't tell if your audience is real gamers or bots.
Users register and forget. Wishlists don't convert because players lose interest before launch.
Platforms don't let you contact users directly. Most games lack even basic email marketing.
Users scattered across Steam, Discord, Twitter, App Store. Impossible to maintain consistent engagement.
## The Solution
Immutable Audience transforms anonymous waitlists into engaged, contactable communities:
| Multiplier | What It Does |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| **Wishlist Acquisition** | For every user you acquire, we multiply the actual number through funnel optimization, retargeting, referrals, and cross-channel connection |
| **Player Conversion** | Increase players who graduate from wishlist to game by keeping them engaged with quests, leaderboards, and personalized marketing |
## Products
High-conversion pre-launch homepage that wraps your existing channels
Deep insights into your audience using Immutable's proprietary data network
Automated lifecycle marketing across email, Telegram, and more
Keep players engaged with on-chain and off-chain quest systems
Partner with content creators to grow your player base
Drop-in snippet for attribution tracking on your marketing sites
Add analytics to your web app or landing page
## Next Steps
Start with your pre-launch landing page
Set up your first quest campaign
Access Hub to get started
Learn about player insights
# Questing
Source: https://docs.immutable.com/docs/products/audience/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/products/hub/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/products/hub/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 [Hub Overview](/docs/products/hub/overview) 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
# REST API
Source: https://docs.immutable.com/docs/products/audience/rest-api
Send events directly from your backend, game server, or payment webhook. No browser or client-side library required.
The REST API is currently in **alpha**. APIs and behavior may change between releases.
**Who is this for?** Backend engineers and game server developers who need to send events from server-side code, such as purchase confirmations, server-authoritative game events, or login handlers.
The Immutable REST API sends events to the Immutable attribution pipeline over HTTP. It uses the same event schemas as the [Tracking Pixel](/docs/products/audience/tracking-pixel) and [Web SDK](/docs/products/audience/web-sdk). Because events originate from your server, they are authoritative and cannot be dropped by ad blockers or manipulated client-side, making the REST API the recommended source for purchase confirmations and other high-value events. Send a JSON payload with the event type, name, and properties, and it flows into the same pipeline as all other surfaces.
## When to Use the REST API
| You want to... | Use this |
| -------------------------------------------------------------- | -------------------------------------------------------- |
| Passively capture page views and clicks on a marketing site | [Tracking Pixel](/docs/products/audience/tracking-pixel) |
| Instrument a web app with typed, explicit events | [Web SDK](/docs/products/audience/web-sdk) |
| Send events from your game server, backend, or webhook handler | **REST API** |
| Send purchase confirmations or server-verified identity events | **REST API** |
## What You Need
* An [Immutable Hub](https://hub.immutable.com) account with a project ([get started here](/docs/products/hub/getting-started))
* A publishable key from your project settings ([API keys guide](/docs/products/hub/api-keys))
## Quick Start
```bash theme={null}
curl -X POST https://api.immutable.com/v1/audience/messages \
-H "Content-Type: application/json" \
-H "x-immutable-publishable-key: YOUR_PUBLISHABLE_KEY" \
-d '{
"messages": [{
"type": "track",
"messageId": "550e8400-e29b-41d4-a716-446655440000",
"eventTimestamp": "2026-04-08T12:00:00Z",
"eventName": "purchase",
"userId": "user-123",
"surface": "web",
"properties": {
"currency": "USD",
"value": 9.99,
"itemId": "sword_01"
},
"context": {
"library": "my-studio-backend",
"libraryVersion": "1.0.0"
}
}]
}'
```
**Response:**
```json theme={null}
{
"success": true,
"accepted": 1,
"rejected": 0
}
```
## Authentication
All requests require a publishable key in the `x-immutable-publishable-key` header. The base URL is `https://api.immutable.com`. Manage your keys in [Immutable Hub](https://hub.immutable.com). See the [API Keys guide](/docs/products/hub/api-keys) for how to create one.
## Consent
You are responsible for obtaining and recording user consent before sending tracking events. The REST API does not enforce consent on your behalf. Only send what the user has consented to.
The same three-tier model (`none` / `anonymous` / `full`) applies as with the Tracking Pixel and Web SDK. See the [Data Dictionary → Consent Model](/docs/products/audience/data-dictionary#consent-model) for what each level allows. In brief: at `none` send nothing, at `anonymous` omit `userId` and PII, at `full` send everything.
### Recording Consent
Call this when a user accepts or declines tracking:
```bash theme={null}
curl -X PUT https://api.immutable.com/v1/audience/tracking-consent \
-H "Content-Type: application/json" \
-H "x-immutable-publishable-key: YOUR_PUBLISHABLE_KEY" \
-d '{
"anonymousId": "anon-device-abc123",
"status": "full",
"source": "cookie-banner-v1"
}'
```
Response: `204 No Content`
| Field | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------------------------------------- |
| `anonymousId` | string | Yes | The anonymous ID used in your events. Max 256 chars. |
| `status` | string | Yes | `none`, `anonymous`, or `full` |
| `source` | string | Yes | Where the consent decision originated (e.g. `cookie-banner-v1`). Max 128 chars. |
### Reading Consent
Retrieve a stored consent preference at session start:
```bash theme={null}
curl "https://api.immutable.com/v1/audience/tracking-consent?anonymousId=anon-device-abc123" \
-H "x-immutable-publishable-key: YOUR_PUBLISHABLE_KEY"
```
```json theme={null}
{ "status": "full" }
```
If no consent has been recorded, the response is `{ "status": "not_set" }`. Treat `not_set` the same as `none`.
## Deleting User Data
To handle a GDPR Right to Erasure request, call this endpoint from your backend with the user's `anonymousId` **or** `userId`, not both.
```bash theme={null}
# By anonymous ID
curl -X DELETE "https://api.immutable.com/v1/audience/data?anonymousId=anon-device-abc123" \
-H "x-immutable-publishable-key: YOUR_PUBLISHABLE_KEY"
# By user ID
curl -X DELETE "https://api.immutable.com/v1/audience/data?userId=user-123" \
-H "x-immutable-publishable-key: YOUR_PUBLISHABLE_KEY"
```
Response: `202 Accepted`
| Field | Where | Required | Description |
| ----------------------------- | ------------ | -------- | ------------------------------------------- |
| `x-immutable-publishable-key` | Header | Yes | Your publishable key |
| `anonymousId` | Query string | One of | The anonymous device or session ID to erase |
| `userId` | Query string | One of | The canonical user ID to erase |
Deletion is processed asynchronously. All tracking data and consent records associated with the identity, including any linked identities, are removed or anonymised across all storage layers.
## API Reference
### Endpoints
| Method | Path | Purpose |
| ------ | ---------------------------------------------- | ----------------------------------- |
| POST | `/v1/audience/messages` | Ingest up to 100 events per request |
| GET | `/v1/audience/tracking-consent?anonymousId=` | Read stored consent status |
| PUT | `/v1/audience/tracking-consent` | Record a consent decision |
| DELETE | `/v1/audience/data?anonymousId=` or `?userId=` | GDPR erasure request |
### Sending Events
**Headers:**
| Header | Required | Value |
| ----------------------------- | -------- | -------------------- |
| `Content-Type` | Yes | `application/json` |
| `x-immutable-publishable-key` | Yes | Your publishable key |
**Response:**
```json theme={null}
{
"success": true,
"accepted": 2,
"rejected": 0
}
```
The endpoint returns `200 OK` even if some messages fail validation. Invalid messages are silently skipped. Check the `rejected` count to detect failures. A `400` means the entire request failed (invalid key or malformed body).
### Message Schema
| Field | Type | Required | Description |
| ---------------- | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | string | Yes | `track`, `identify`, `alias`, `page`, or `screen` |
| `messageId` | string (UUID) | Yes | Unique per message, used for deduplication |
| `eventTimestamp` | string (ISO 8601) | Yes | When the event occurred, not when you send it. Must include a timezone offset (e.g. `2026-04-08T12:00:00Z`). Up to 30 days in the past accepted. |
| `context` | object | Yes | Must include `library` (your system name) and `libraryVersion` |
| `userId` | string | — | Your canonical user ID. Required on most types unless `anonymousId` is set. |
| `anonymousId` | string | — | Anonymous device or session ID. Required if `userId` is absent. |
| `surface` | string | — | `web`, `pixel`, `unity`, or `unreal` |
| `test` | boolean | — | Marks the event as test traffic. Use during development or QA to separate test events from production data. |
Most string fields accept up to 256 characters. `pageUrl`, `pagePath`, and `pageReferrer` accept up to 2048.
Use these to pass environment metadata alongside the required `library` and `libraryVersion`. Include whichever are relevant. All are optional.
| Field | Description |
| ----------------- | --------------------------------------------------------- |
| `userAgent` | Browser or client user agent |
| `browserLanguage` | Browser language from `navigator.language` (e.g. `en-US`) |
| `locale` | App or game locale setting (e.g. `en-US`) |
| `timezone` | e.g. `America/New_York` |
| `screen` | Screen resolution (e.g. `1920x1080`) |
| `pageUrl` | Full page URL |
| `pagePath` | URL path only |
| `pageReferrer` | Referrer URL |
| `pageTitle` | Page title |
```json theme={null}
"context": {
"library": "my-studio-backend",
"libraryVersion": "1.0.0",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"timezone": "America/New_York",
"pageUrl": "https://mygame.com/shop",
"pageTitle": "Shop"
}
```
### Event Types
Record a named event. The most common message type.
**Also required:** `eventName`, plus `userId` or `anonymousId`
```json theme={null}
{
"type": "track",
"messageId": "550e8400-e29b-41d4-a716-446655440001",
"eventTimestamp": "2026-04-08T12:00:00Z",
"eventName": "purchase",
"userId": "user-123",
"surface": "web",
"properties": { "currency": "USD", "value": 9.99, "itemId": "sword_01" },
"context": { "library": "my-studio-backend", "libraryVersion": "1.0.0" }
}
```
Use any `eventName`. [Predefined events](/docs/products/audience/data-dictionary#predefined-events) have defined property schemas. See the Data Dictionary for the full list. Custom event names are also supported.
Associates a user ID with their activity and traits. Send when a user logs in, creates an account, or updates their profile.
**Also required:** `userId` or `anonymousId`
```json theme={null}
{
"type": "identify",
"messageId": "550e8400-e29b-41d4-a716-446655440004",
"eventTimestamp": "2026-04-08T12:00:00Z",
"userId": "user-123",
"identityType": "passport",
"traits": { "name": "Player One", "email": "player@example.com" },
"context": { "library": "my-studio-backend", "libraryVersion": "1.0.0" }
}
```
**`identityType` values:** `passport`, `steam`, `epic`, `google`, `apple`, `discord`, `email`, `custom`
Link two account IDs that belong to the same player. Use when a player connects a second account with a different provider, for example a Steam ID linked to a Passport account.
**Also required:** `fromId`, `toId` (must be different). `from` is the account being linked. `to` is the player's canonical account.
```json theme={null}
{
"type": "alias",
"messageId": "550e8400-e29b-41d4-a716-446655440005",
"eventTimestamp": "2026-04-08T12:00:00Z",
"fromId": "76561198000000001",
"fromType": "steam",
"toId": "user-123",
"toType": "passport",
"context": { "library": "my-studio-backend", "libraryVersion": "1.0.0" }
}
```
Record a page view. Useful for server-side rendering where you capture page loads on the server.
**Also required:** `userId` or `anonymousId`
```json theme={null}
{
"type": "page",
"messageId": "550e8400-e29b-41d4-a716-446655440006",
"eventTimestamp": "2026-04-08T12:00:00Z",
"userId": "user-123",
"properties": { "section": "shop" },
"context": {
"library": "my-studio-backend",
"libraryVersion": "1.0.0",
"pageUrl": "https://mygame.com/shop",
"pageTitle": "Shop"
}
}
```
Record a screen view in a game client or app. Same schema as `page`.
```json theme={null}
{
"type": "screen",
"messageId": "550e8400-e29b-41d4-a716-446655440007",
"eventTimestamp": "2026-04-08T12:00:00Z",
"userId": "user-123",
"properties": { "screenName": "main-menu" },
"context": { "library": "my-game-server", "libraryVersion": "1.2.3" }
}
```
For predefined event names and their property schemas (`purchase`, `progression`, `resource`, `sign_up`, and more), see the [Data Dictionary](/docs/products/audience/data-dictionary#predefined-events).
### Error Responses
| Status | Meaning |
| ------ | ---------------------------------------------------------- |
| `200` | Request accepted. Check `accepted` and `rejected` counts. |
| `400` | Malformed request body or key format invalid. |
| `401` | Publishable key not recognised. Check your key is correct. |
| `500` | Server error. Retry with exponential back-off. |
## Next Steps
How tracking data powers player attribution and Hub reports
Full reference of event schemas and consent levels
Passive tracking snippet for marketing sites and landing pages
Typed SDK for web games, marketing sites, and SPAs
In-game tracking for Unity desktop builds
Send attributed conversions back to ad networks to improve campaign optimisation
Manage your publishable and secret keys
# Tracking Pixel
Source: https://docs.immutable.com/docs/products/audience/tracking-pixel
Lightweight JavaScript snippet for marketing sites. Passively captures page views, attribution, and interactions with no SDK installation
The Tracking Pixel is currently in **alpha**. APIs and behavior may change between releases.
**Who is this for?** Marketers and web developers who want passive tracking on marketing sites, landing pages, or web shops with no package install or build step required.
The Immutable Tracking Pixel is a lightweight JavaScript snippet that automatically captures page views, attribution signals, form submissions, and outbound link clicks as part of the Immutable attribution system. Paste it into your site's `` and it starts collecting with no build step or configuration required. For web games and SPAs where you need custom events or user identity, use the [Web SDK](/docs/products/audience/web-sdk). The two work side by side on the same site.
## What You Need
* An [Immutable Hub](https://hub.immutable.com) account with a project ([get started here](/docs/products/hub/getting-started))
* A publishable API key from your project settings ([API keys guide](/docs/products/hub/api-keys))
* Access to the `` tag of the site where you want to install the pixel
## Install the Snippet
Paste this into your site's `` tag:
```html theme={null}
```
Replace `YOUR_PUBLISHABLE_KEY` with your project's publishable key from [Hub](https://hub.immutable.com).
This snippet sets `consent` to `anonymous`, which starts collecting anonymous device signals and page views immediately. If your site requires explicit cookie consent before any tracking, see [Consent Modes](#consent-modes) below.
The script loads asynchronously and does not block page rendering. It's under 10 KB gzipped.
## Verify the Integration
Open your site with the snippet installed, then check the browser developer tools:
1. **Network tab**: Look for a request to `https://cdn.immutable.com/pixel/v1/imtbl.js` (the pixel script loading)
2. **Network tab**: Look for POST requests to `https://api.immutable.com` (events being sent). Events flush every 5 seconds or when 20 events accumulate.
3. **Console**: Type `window.__imtbl` to inspect the command queue
If you see the script loading and POST requests firing, the pixel is working.
## Consent Modes
The `consent` option controls what the pixel collects. The default is `none`, meaning the pixel loads but does not collect anything until consent is set. See the [Data Dictionary](/docs/products/audience/data-dictionary#consent-level-matrix) for what is collected at each level.
The pixel does not provide a consent UI. You are responsible for building the cookie banner or privacy prompt and calling `window.__imtbl.push(['consent', level])` when the user makes a choice.
### Setting Consent at Init
To start with the pixel inert (recommended if you have a cookie consent banner):
```html theme={null}
```
Then upgrade consent after the user interacts with your cookie banner:
```javascript theme={null}
// After user accepts analytics cookies
window.__imtbl.push(['consent', 'anonymous']);
// Or after user accepts marketing cookies
window.__imtbl.push(['consent', 'full']);
```
### Automatic CMP Detection
If your site uses a Consent Management Platform (such as OneTrust or Cookiebot), the pixel can detect consent state automatically:
```html theme={null}
```
When `consentMode` is set to `auto`, the pixel starts in `none` and checks for these standards:
1. **Google Consent Mode v2**: reads `analytics_storage` and `ad_storage` from `window.dataLayer`
2. **IAB TCF v2**: reads purpose consents via `window.__tcfapi`
The pixel upgrades consent automatically when the CMP signals it, and continues listening for changes (e.g. when a user updates their cookie preferences).
`consentMode` and `consent` are mutually exclusive. Do not set both.
### Downgrading Consent
You can [downgrade consent](/docs/products/audience/data-dictionary#downgrading-consent) at any time. Downgrading to `'none'` purges all queued events:
```javascript theme={null}
window.__imtbl.push(['consent', 'none']);
```
## What the Pixel Tracks Automatically
Once consent is set to `anonymous` or higher, the pixel captures these events with no additional code:
| Event | When it fires | What it captures |
| ----------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **Page view** | Every page load | [Attribution signals](/docs/products/audience/data-dictionary#attribution-signals) (UTMs, click IDs, referrer, landing page, referral codes) |
| **Session start** | New session begins | Session ID |
| **Session end** | Page unload | Session ID, duration in seconds |
| **Form submission** | HTML form submit | Form action, ID, name, field names. At `full` consent: SHA-256 hashed email. |
| **Outbound link click** | Click on external link | Destination URL, link text, element ID |
| **Scroll depth** | Scroll milestone reached (25%, 50%, 75%, 90%, 100%) | Milestone depth, session ID. Works on standard document scroll and on SPA-style internal scroll containers. |
## Cross-Subdomain Tracking
To track users across subdomains (e.g. `www.example.com` and `shop.example.com`), set the `domain` option:
```javascript theme={null}
window.__imtbl.push(["init",{"key":"YOUR_PUBLISHABLE_KEY","consent":"anonymous","domain":".example.com"}]);
```
## Content Security Policy
If your site enforces a Content-Security-Policy header, add these directives:
```
script-src: https://cdn.immutable.com
connect-src: https://api.immutable.com
```
For nonce-based CSP, add the `nonce` attribute to the inline `
```
## Configuration
### Init Options
Pass these options to the `init` command when initializing the pixel.
| Option | Type | Default | Description |
| -------------------- | ------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------- |
| `key` | `string` | **(required)** | Your Immutable publishable API key from [Hub](/docs/products/hub/api-keys) |
| `consent` | `'none'` \| `'anonymous'` \| `'full'` | `'none'` | Initial consent level |
| `consentMode` | `'auto'` | `undefined` | Enable automatic CMP detection (Google Consent Mode v2, IAB TCF v2). Mutually exclusive with `consent`. |
| `testMode` | `boolean` | `false` | When `true`, stamps all events with `test: true` so they can be filtered from production analytics. |
| `domain` | `string` | Current hostname | Cookie domain scope. Set to `.example.com` for cross-subdomain tracking. |
| `autocapture.forms` | `boolean` | `true` | Auto-capture HTML form submissions |
| `autocapture.clicks` | `boolean` | `true` | Auto-capture outbound link clicks |
| `autocapture.scroll` | `boolean` | `true` | Auto-capture scroll depth milestones |
```html theme={null}
```
### Command Queue
All commands use the `window.__imtbl.push()` pattern. The command queue buffers calls made before the pixel script finishes loading, then replays them in order.
| Command | Arguments | Description |
| ----------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `['init', options]` | Init options (see above) | Initialize the pixel. Must be called exactly once. |
| `['consent', level]` | `'none'` \| `'anonymous'` \| `'full'` | Update the consent level at runtime. Downgrading to `none` purges PII from the local event queue. |
| `['page', properties?]` | `Record` (optional) | Manually fire a page view with optional custom properties. |
The pixel fires a page view automatically on load. Use the `page` command to manually trigger additional page views on SPA route changes. Calling `page` also resets scroll depth milestones so each route starts with a fresh counter:
```javascript theme={null}
window.__imtbl.push(['page', { section: 'pricing' }]);
```
### Auto-Capture
By default, the pixel automatically captures form submissions and outbound link clicks. You can disable either or both:
```javascript theme={null}
// Disable form capture, keep click tracking
window.__imtbl.push(["init",{
"key":"YOUR_PUBLISHABLE_KEY",
"consent":"anonymous",
"autocapture":{"forms":false,"clicks":true}
}]);
// Disable all auto-capture (only page views and sessions)
window.__imtbl.push(["init",{
"key":"YOUR_PUBLISHABLE_KEY",
"consent":"anonymous",
"autocapture":{"forms":false,"clicks":false}
}]);
```
### Event Delivery
* Events are batched and flushed every **5 seconds** or when **20 events** accumulate, whichever comes first
* The `session_end` event uses `navigator.sendBeacon()` to ensure delivery on page unload
* All events include `surface: "pixel"` to distinguish them from Web SDK events in the pipeline
## FAQ
Yes, they're complementary, not alternatives. The Pixel handles passive capture (page views, attribution, form submissions, outbound clicks) with no code beyond the snippet. Add the [Web SDK](/docs/products/audience/web-sdk) when you need custom events, user identity, or SPA route tracking.
The pixel uses first-party cookies and standard `fetch()` requests, which minimizes interference from ad blockers. First-party domain hosting further reduces this risk.
Partially. The pixel does not detect route changes automatically — call `window.__imtbl.push(['page'])` on each SPA route change to fire a page view. This also resets scroll depth milestones so the new route starts with a fresh counter.
Scroll depth tracking works on internal scroll containers (the pattern used by Angular, Vue, and React apps) as well as standard document scroll, so no extra configuration is needed there.
**Cross-origin iframes:** the same-origin policy prevents the pixel from observing scroll inside a cross-origin iframe. If your site loads content this way, install the pixel inside the iframe as well.
Under 10 KB gzipped. The script loads asynchronously and does not block page rendering.
Chrome 80+, Firefox 78+, Safari 14+, Edge 80+.
Use `consentMode: 'auto'` for automatic detection. Or manually call `window.__imtbl.push(['consent', 'full'])` from your CMP's callback.
Erasure is a server-side operation. Call `DELETE /v1/audience/data` from your backend with the user's `userId` or `anonymousId`. See [Deleting User Data](/docs/products/audience/rest-api#deleting-user-data) in the REST API docs for the full request shape.
On the client side, downgrade consent to `none` after the erasure is accepted to stop further collection:
```javascript theme={null}
window.__imtbl.push(['consent', 'none']);
```
## Next Steps
How tracking data powers player attribution and Hub reports
Full reference of event schemas and consent levels
Typed SDK for web games, marketing sites, and SPAs
In-game tracking for Unity desktop builds
Send events from your backend or game server
Send attributed conversions back to ad networks to improve campaign optimisation
Manage your publishable and secret keys
# Unity SDK
Source: https://docs.immutable.com/docs/products/audience/unity-sdk
Typed tracking SDK for Unity games on PC and mobile. Captures session lifecycle, progressions, purchases, and identity signals
The Unity SDK is currently in **alpha**. APIs and behavior may change between releases.
**Who is this for?** Unity engineers building games on **Windows, macOS, Linux, iOS, or Android** who want typed in-game tracking, identity, and offline-safe queuing.
The Immutable Unity SDK instruments the in-game layer of the player journey as part of the Immutable attribution system, connecting the campaigns and referral links that drove players to your game with their actual in-game behavior. Session lifecycle and a launch event are captured automatically. In-game moments like progressions, purchases, and milestones are triggered by your code.
## What You Need
* An [Immutable Hub](https://hub.immutable.com) account with a project ([get started here](/docs/products/hub/getting-started))
* A publishable key from your project settings ([API keys guide](/docs/products/hub/api-keys))
* A target build platform. Tested on:
* **Windows 10+**
* **macOS Sequoia 15.7.4+**
* **Linux Ubuntu 22.04 LTS+**
* **iOS 13+**
* **Android 5.0+ (API level 21+)**
* A Unity project on a supported version:
* [Unity 2021.3.45f2+](unityhub://2021.3.45f2/88f88f591b2e)
* [Unity 2022.3.62f2+](unityhub://2022.3.62f2/7670c08855a9)
* [Unity 6000.4.0f1+](unityhub://6000.4.0f1/8cf496087c8f)
* A Unity project using **Mono** or **IL2CPP** as the scripting backend (both are fully supported)
Mobile builds targeting iOS or Android with attribution signals require additional setup. See [Mobile](#mobile).
## Installation
Install the package via Unity Package Manager using a Git URL.
1. In Unity, open **Window → Package Manager**.
2. Click the **+** button in the top-left, then **Add package from git URL...**.
3. Paste the URL below and click **Add**:
```
https://github.com/immutable/unity-immutable-sdk.git?path=src/Packages/Audience#main
```
The package appears in your project as **Immutable Audience** under **Packages** in the Project window.
The `#main` ref tracks the latest commit on the SDK main branch. For reproducible builds, replace `#main` with a specific commit SHA, for example: `?path=src/Packages/Audience#a1b2c3d`.
To update later, re-open Package Manager, click the package, and press **Update**, or edit the entry in `Packages/manifest.json` directly.
## Quick Start
Prefer learning from a running project? A working Unity sample lives at [unity-immutable-sdk/examples/audience](https://github.com/immutable/unity-immutable-sdk/tree/main/examples/audience). Clone the repository, set your publishable key, and run.
Call `ImmutableAudience.Init` once at game launch. You can mark a static method with `[RuntimeInitializeOnLoadMethod]` to have Unity call it before any scene loads.
```csharp theme={null}
using Immutable.Audience;
using UnityEngine;
public static class Analytics
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Init()
{
ImmutableAudience.Init(new AudienceConfig
{
PublishableKey = "YOUR_PUBLISHABLE_KEY",
Consent = ConsentLevel.Anonymous,
DistributionPlatform = DistributionPlatforms.Steam,
Debug = true,
});
}
}
```
Consent defaults to `ConsentLevel.None`, which suppresses all tracking. The example above sets `Anonymous` directly so events flow immediately. For an in-game privacy prompt, see the next step.
The SDK uses a three-tier consent model (`None`, `Anonymous`, `Full`). The default is `None`. The SDK does not collect anything until you explicitly raise it. The SDK does not provide a consent UI. Build your own in-game prompt and call `SetConsent` when the player responds.
```csharp theme={null}
// Player accepts anonymous tracking
ImmutableAudience.SetConsent(ConsentLevel.Anonymous);
// Player logs in and accepts full tracking
ImmutableAudience.SetConsent(ConsentLevel.Full);
// Player revokes consent (clears identity, purges queued events, stops collection)
ImmutableAudience.SetConsent(ConsentLevel.None);
```
`SetConsent` takes effect immediately. Lowering the level purges queued events the new level no longer permits. See the [Data Dictionary](/docs/products/audience/data-dictionary#consent-model) for what each level collects.
Use `ImmutableAudience.Track` to log a player action. The SDK ships with [predefined events](/docs/products/audience/data-dictionary#predefined-events) for common player actions and accepts any custom event string.
```csharp theme={null}
// Predefined event with typed properties
ImmutableAudience.Track(new Purchase
{
Currency = "USD",
Value = 9.99m,
ItemName = "Sword of Truth",
});
// Custom event with any properties
ImmutableAudience.Track("tutorial_complete", new Dictionary
{
["stepCount"] = 5,
});
```
Identifies the player by associating their userId with their in-game activity and traits. Call at login or account connection. Requires `Full` consent.
```csharp theme={null}
ImmutableAudience.Identify(
userId: "76561190000000000",
identityType: IdentityType.Steam,
traits: new Dictionary
{
["personaName"] = "Player1",
});
```
Called automatically on `Application.quitting`. For manual teardown:
```csharp theme={null}
// Manual teardown: flush pending events, then stop the SDK.
await ImmutableAudience.FlushAsync();
ImmutableAudience.Shutdown();
```
### Complete Example
```csharp theme={null}
using System.Collections.Generic;
using Immutable.Audience;
using UnityEngine;
public static class Analytics
{
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static async void Init()
{
// 1. Initialize
ImmutableAudience.Init(new AudienceConfig
{
PublishableKey = "YOUR_PUBLISHABLE_KEY",
});
// 2. Set consent after privacy prompt
ImmutableAudience.SetConsent(ConsentLevel.Anonymous);
// 3. Track player actions
ImmutableAudience.Track(new Purchase { Currency = "USD", Value = 9.99m });
// 4. Identify after login (requires Full consent)
ImmutableAudience.SetConsent(ConsentLevel.Full);
ImmutableAudience.Identify("76561190000000000", IdentityType.Steam, new Dictionary
{
["personaName"] = "Player1",
});
// 5. Clean up on exit (auto-called on Application.quitting)
await ImmutableAudience.FlushAsync();
ImmutableAudience.Shutdown();
}
}
```
## Verify the Integration
After Init, the SDK exposes its state through read-only properties on `ImmutableAudience`. Log them to confirm a healthy install:
```csharp theme={null}
Debug.Log($"Initialized: {ImmutableAudience.Initialized}");
Debug.Log($"AnonymousId: {ImmutableAudience.AnonymousId}");
Debug.Log($"SessionId: {ImmutableAudience.SessionId}");
Debug.Log($"QueueSize: {ImmutableAudience.QueueSize}");
```
Pair with an `OnError` callback in your `AudienceConfig` to surface failures (see [Error Handling](#error-handling)):
```csharp theme={null}
OnError = err => Debug.LogWarning($"{err.Code}: {err.Message}"),
```
A healthy install:
| Property | Healthy value |
| ------------- | ----------------------------------------------------------------------------------------------------------- |
| `Initialized` | `true` |
| `AnonymousId` | non-null GUID |
| `SessionId` | non-null GUID once [`session_start`](/docs/products/audience/data-dictionary#auto-tracked-events) has fired |
| `QueueSize` | `1` immediately after a `Track` call, then `0` once flushed |
| Unity Console | no SDK warnings, no `OnError` invocations |
## Mobile
Mobile adds opt-in device-level attribution signals for iOS and Android.
### Attribution opt-in
Two gates must both be set before any mobile attribution data ships:
| Gate | Where |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **Build-time** `AUDIENCE_MOBILE_ATTRIBUTION` | **Player Settings → Other Settings → Scripting Define Symbols**. Set separately for each player target (iOS and Android). |
| **Runtime** `EnableMobileAttribution = true` | [`AudienceConfig` passed to `Init`](#initaudienceconfig). Controls whether attribution data is collected at runtime. |
Setting `AUDIENCE_MOBILE_ATTRIBUTION` does three things: adds the `AD_ID` manifest permission on Android, compiles in native attribution code, and switches the iOS privacy manifest to the tracking variant (`NSPrivacyTracking = true`).
If neither gate is set, the build ships without attribution code: no `AD_ID` manifest permission, no native tracking, and `NSPrivacyTracking = false` in the iOS privacy manifest.
### Platform setup
**1. Create a Mobile Build Settings asset**
The iOS build post-processors need a `NSUserTrackingUsageDescription` string. Apple rejects builds that omit this key. Create the asset once per Unity project:
1. **Assets → Create → Immutable Audience → Mobile Build Settings**
2. Set **Tracking Usage Description** to a specific description of what you collect and why. Apple rejects generic copy.
**2. Request [App Tracking Transparency (ATT)](https://developer.apple.com/documentation/apptrackingtransparency) authorization**
Call `RequestTrackingAuthorizationAsync` at a contextually appropriate moment. The IDFA, when authorized, ships automatically in the next [`game_launch`](/docs/products/audience/data-dictionary#auto-tracked-events). A `tracking_authorization_changed` event fires on any subsequent status transition.
ATT requires iOS 14+. On iOS 13 the call resolves to `NotDetermined` and IDFA is unavailable. All other SDK features work normally.
```csharp theme={null}
var status = await ImmutableAudience.RequestTrackingAuthorizationAsync();
```
**[Privacy manifest](https://developer.apple.com/documentation/bundleresources/privacy_manifest_files) (automatic)**
No action required. The SDK ships a `PrivacyInfo.xcprivacy` that Unity merges into the generated Xcode project. The post-processor selects the correct variant based on the `AUDIENCE_MOBILE_ATTRIBUTION` define:
| Build | `NSPrivacyTracking` | Declared data types |
| ----------------------------- | ------------------- | --------------------------------------------------------- |
| Default | `false` | IDFV (`NSPrivacyCollectedDataTypeDeviceID`) |
| `AUDIENCE_MOBILE_ATTRIBUTION` | `true` | IDFV + IDFA (`NSPrivacyCollectedDataTypeAdvertisingData`) |
No Required Reason APIs are used by the SDK. `NSPrivacyAccessedAPITypes` in the manifest reflects Unity engine internals only.
**1. Add the Google Advertising ID (GAID) dependency**
GAID requires the Play Services Ads Identifier dependency. Without it, a `ClassNotFoundException` is logged on every launch when `AUDIENCE_MOBILE_ATTRIBUTION` is set. Enable **Custom Main Gradle Template** under **Player Settings → Publishing Settings**, then add to `Assets/Plugins/Android/mainTemplate.gradle`. The `**DEPS**` token is a Unity placeholder expanded at build time. Do not remove it:
```gradle theme={null}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
**DEPS**
implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1'
}
```
The SDK fetches GAID on a background thread (required by Google).
GAID is unavailable on first launch. The first [`game_launch`](/docs/products/audience/data-dictionary#auto-tracked-events) event will not include `gaid` or `gaidLimitAdTracking`. Both are available from the second launch onwards.
**[Play Install Referrer](https://developer.android.com/google/play/installreferrer) (automatic)**
No setup required. The referrer string ships as a dedicated [`install_referrer_received`](/docs/products/audience/data-dictionary#auto-tracked-events) event fired once per install.
## API Reference
All methods are static on `Immutable.Audience.ImmutableAudience` and are safe to call from any thread after `Init` returns.
Starts the SDK. Call once at game launch (see the [Quick Start](#quick-start)). Subsequent calls are ignored with a warning.
Throws `ArgumentNullException` if `config` is null, and `ArgumentException` if `PublishableKey` is empty.
```csharp theme={null}
ImmutableAudience.Init(new AudienceConfig
{
PublishableKey = "YOUR_PUBLISHABLE_KEY",
Consent = ConsentLevel.None,
DistributionPlatform = DistributionPlatforms.Steam,
Debug = true,
OnError = err => Debug.LogWarning($"{err.Code}: {err.Message}"),
});
```
**`AudienceConfig` fields:**
| Field | Type | Required | Default | Description |
| ------------------------- | ------------------------ | -------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `PublishableKey` | `string` | Yes | (none) | API key from [Hub](https://hub.immutable.com). |
| `Consent` | `ConsentLevel` | No | `None` | Initial consent level. The SDK collects nothing until this is `Anonymous` or higher. |
| `DistributionPlatform` | `string?` | No | null | The platform the build is shipping on. `Init` normalizes this to lowercase, so case at the call site does not matter. Use `DistributionPlatforms.*` constants for the standard values ([see below](#distribution-platforms)). |
| `Debug` | `bool` | No | `false` | Enable SDK warnings via `UnityEngine.Debug.Log`. Disable in release builds. |
| `TestMode` | `bool` | No | `false` | Mark all events sent in this session as test traffic. Use during development or QA to separate test events from production data in analytics. |
| `FlushIntervalSeconds` | `int` | No | `5` | How often pending events are flushed to the backend. |
| `FlushSize` | `int` | No | `20` | Flush as soon as this many events are queued. |
| `OnError` | `Action?` | No | null | Callback fired on errors. Runs on a background thread. Marshal to the main thread before touching Unity APIs. `AudienceError.Code` is one of `FlushFailed`, `ValidationRejected`, `ConsentSyncFailed`, `NetworkError`, `ConsentPersistFailed` (see [Error Handling](#error-handling) for descriptions). |
| `PersistentDataPath` | `string?` | No | (auto from Unity) | Directory for the SDK identity, consent, and queued-event files. The Unity integration fills this in from `Application.persistentDataPath`. |
| `PackageVersion` | `string` | No | SDK package version | Library version sent on every message. Override only if you need to report a different version (e.g. wrapping the SDK in your own package). |
| `ShutdownFlushTimeoutMs` | `int` | No | `2000` | Maximum time `Shutdown` waits for the final flush. |
| `EnableMobileAttribution` | `bool` | No | `false` | Opts into mobile attribution signals ([ATT](https://developer.apple.com/documentation/apptrackingtransparency)/IDFA/[SKAdNetwork](https://developer.apple.com/documentation/storekit/skadnetwork) on iOS, GAID/Install Referrer on Android). Both this flag and the `AUDIENCE_MOBILE_ATTRIBUTION` scripting define must be set. See [Mobile](#mobile). |
#### Distribution platforms
`DistributionPlatform` is a free-form string sent in the `distributionPlatform` property of the [`game_launch`](/docs/products/audience/data-dictionary#auto-tracked-events) event. `Init` lowercases the value before it goes on the wire. Use the `DistributionPlatforms` constants when one matches:
| Constant | Value |
| ---------------------------------- | -------------- |
| `DistributionPlatforms.Steam` | `"steam"` |
| `DistributionPlatforms.Epic` | `"epic"` |
| `DistributionPlatforms.GOG` | `"gog"` |
| `DistributionPlatforms.Itch` | `"itch"` |
| `DistributionPlatforms.Standalone` | `"standalone"` |
If you ship on a platform not in this list, pass the lowercase short name as a string.
Changes the consent level. Pass `ConsentLevel.None`, `ConsentLevel.Anonymous`, or `ConsentLevel.Full`. Takes effect immediately. Lowering the level purges queued events that the new level no longer permits. Lowering to `None` also clears the anonymous ID.
```csharp theme={null}
ImmutableAudience.SetConsent(ConsentLevel.Full);
```
The new level is persisted to disk so it survives the next launch. The SDK also notifies the backend asynchronously for audit logging. Failures fire `OnError` with `AudienceErrorCode.ConsentSyncFailed` but do not affect local state.
Sends a predefined event. See the [Data Dictionary](/docs/products/audience/data-dictionary#predefined-events) for available event classes and their schemas.
| Parameter | Type | Description |
| --------- | -------- | --------------------------------------------------------------------- |
| `evt` | `IEvent` | The typed event instance. Null events are dropped with a log warning. |
**Requires:** `Anonymous` or `Full` consent. Calls at `None` are silently dropped.
```csharp theme={null}
ImmutableAudience.Track(new Purchase { Currency = "USD", Value = 9.99m });
```
Sends a custom event with arbitrary properties. No validation runs on this overload. Prefer [`Track(IEvent)`](#trackievent) for predefined events. Property values can be any JSON-serializable primitive or string. Strings are truncated at 256 characters.
| Parameter | Type | Description |
| ------------ | ----------------------------- | ------------------------------------------------------------------------------------------------ |
| `eventName` | `string` | Required, non-empty. The custom event name. Null or empty values are dropped with a log warning. |
| `properties` | `Dictionary?` | Optional. Event properties. Defaults to `null`. |
**Requires:** `Anonymous` or `Full` consent.
```csharp theme={null}
ImmutableAudience.Track("tutorial_complete", new() { ["stepCount"] = 5 });
```
Associates the player's userId with their in-game activity and traits. Call at login or account connection. Use `IdentityType.Custom` for providers not in the enum.
| Parameter | Type | Description |
| -------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `userId` | `string` | Required, non-empty. The player's identifier in the chosen provider. The SDK silently drops calls with null or empty `userId` (a `Debug.LogWarning` is emitted) so a missing ID does not crash the game. Gate calls on a populated value during development. |
| `identityType` | `IdentityType` | Required. Which provider the ID comes from (see table below). |
| `traits` | `Dictionary?` | Optional. Custom user traits (e.g. `personaName`). Defaults to `null`. |
**Requires:** `Full` consent. Calls at lower levels are dropped with a log warning.
```csharp theme={null}
ImmutableAudience.Identify("76561190000000000", IdentityType.Steam, new() { ["personaName"] = "Player1" });
```
**Identity types:**
| Value | Provider |
| ----------------------- | ---------------------- |
| `IdentityType.Passport` | Immutable Passport |
| `IdentityType.Steam` | Steam |
| `IdentityType.Epic` | Epic Games |
| `IdentityType.Google` | Google |
| `IdentityType.Apple` | Apple |
| `IdentityType.Discord` | Discord |
| `IdentityType.Email` | Email address |
| `IdentityType.Custom` | Custom identity system |
Links two user IDs that belong to the same player. Call when a player connects a second account (e.g. a player with an internal game account ID later signs in via Steam).
| Parameter | Type | Description |
| ---------- | -------------- | ----------------------------------------------- |
| `fromId` | `string` | Required, non-empty. The account being linked. |
| `fromType` | `IdentityType` | Required. The provider for `fromId`. |
| `toId` | `string` | Required, non-empty. The player's main account. |
| `toType` | `IdentityType` | Required. The provider for `toId`. |
**Requires:** `Full` consent. Both `fromType` and `toType` are required for data-deletion matching.
```csharp theme={null}
ImmutableAudience.Alias("76561190000000000", IdentityType.Steam, "internal-player-id-12345", IdentityType.Custom);
```
Wipes the current player's identity, generates a fresh anonymous ID, and **discards queued events** (memory and disk) so the next player on the device isn't attributed to the previous one. Call when a player logs out.
To send queued events before they're discarded:
```csharp theme={null}
await ImmutableAudience.FlushAsync();
ImmutableAudience.Reset();
```
Asks the backend to erase the player's data. Returns a `Task` you can await for acknowledgement, or discard for fire-and-forget.
| Parameter | Type | Description |
| --------- | --------- | -------------------------------------------------------------------------------------------------- |
| `userId` | `string?` | Optional. The known user ID to target. When omitted (or `null`), the current anonymous ID is used. |
```csharp theme={null}
await ImmutableAudience.DeleteData("76561190000000000");
```
Erasure is server-side. After the request is accepted, also call `Reset` on the client so the next session isn't linked to the deleted user. See the [REST API](/docs/products/audience/rest-api#deleting-user-data) for the request shape.
Sends all queued events to the server immediately. Returns a `Task` that completes when the queue is empty or a backoff window prevents further sends. Call before `Reset` (which discards queued events) or `Shutdown` (which is capped at `ShutdownFlushTimeoutMs`) if you want every pending event delivered first.
```csharp theme={null}
await ImmutableAudience.FlushAsync();
```
Flushes any pending events (capped at `ShutdownFlushTimeoutMs`, default 2 seconds) and stops the SDK. Called automatically on `Application.quitting`. You do not need to call it yourself unless you want to control the flush timing or reinitialize with new config.
```csharp theme={null}
ImmutableAudience.Shutdown();
```
Shows the iOS [App Tracking Transparency](https://developer.apple.com/documentation/apptrackingtransparency) prompt and returns the user's decision. The prompt appears once per app install; subsequent calls return the cached status without showing the prompt again.
On non-iOS platforms, iOS 13, or before the SDK is initialized, resolves to `NotDetermined`.
**Requires:** `AUDIENCE_MOBILE_ATTRIBUTION` define + `EnableMobileAttribution = true`.
```csharp theme={null}
var status = await ImmutableAudience.RequestTrackingAuthorizationAsync();
```
| Value | Meaning |
| --------------- | ----------------------------------------------------------------------------- |
| `NotDetermined` | Not yet prompted, dismissed, or non-iOS platform |
| `Restricted` | Restricted by device policy or parental controls. System prompt is not shown. |
| `Denied` | User denied tracking. IDFA is unavailable. |
| `Authorized` | User authorized tracking. IDFA is included in the next `game_launch`. |
Call at a moment when the value exchange is clear to the player. Apple's HIG forbids prompting on cold launch. After the user responds, the SDK fires `tracking_authorization_changed` if the status differs from the previously stored value.
Read-only properties for inspecting SDK state. Use these to verify the SDK is initialized, gate UI elements that depend on consent state (the Unity SDK does not currently ship dedicated `CanTrack` or `CanIdentify` helpers, so read `CurrentConsent` and compare against `ConsentLevel.Anonymous` or `ConsentLevel.Full` instead), or watch the queue size during development.
| Property | Type | Description |
| ---------------- | -------------- | --------------------------------------------------------------------------------------------------------------- |
| `Initialized` | `bool` | True between `Init()` and `Shutdown()` |
| `CurrentConsent` | `ConsentLevel` | The current consent level |
| `UserId` | `string?` | Set by the most recent `Identify` call. Null before `Identify`, after `Reset`, or when consent is below `Full`. |
| `AnonymousId` | `string?` | Persistent ID separate from `UserId` and `SessionId`. Null while consent is `None`. |
| `SessionId` | `string?` | The current session's GUID. Null while consent is `None`. |
| `QueueSize` | `int` | Number of unsent events (memory + disk) |
```csharp theme={null}
// Gate a tracking-dependent UI element on consent
if (ImmutableAudience.CurrentConsent >= ConsentLevel.Anonymous)
{
ShowAnalyticsBadge();
}
// Force-flush the queue when it grows large
if (ImmutableAudience.Initialized && ImmutableAudience.QueueSize > 100)
{
await ImmutableAudience.FlushAsync();
}
```
## Event Delivery
* Events are batched and flushed every **5 seconds** or when **20 events** accumulate. Configure via the `FlushIntervalSeconds` and `FlushSize` fields on `AudienceConfig` (see [`Init`](#api-reference)).
* `Application.quitting` triggers a final flush capped at `ShutdownFlushTimeoutMs` (default 2 seconds).
* Events not flushed before quit are persisted to disk and shipped on the next launch. Events older than 30 days are discarded.
## Error Handling
Flushes run on a background thread with automatic retries. Most failures don't surface to your code. Pass an `OnError` callback when configuring [`Init`](#api-reference) to observe them.
`AudienceError.Code` is one of:
| Code | Description |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FlushFailed` | An event batch failed to flush. Either a local storage read error (batch dropped) or a non-2xx, non-4xx server response (typically 5xx). On 5xx, the batch is retained and retried with exponential `backoff`. |
| `ValidationRejected` | The server rejected an event batch with a 4xx status. The batch was dropped. Retrying will not help (typically a malformed payload). |
| `ConsentSyncFailed` | Failed to sync a consent change to the backend. The local consent level has already been applied. The server-side audit log may be out of date. |
| `NetworkError` | A network call failed (exception, timeout, or non-2xx response on data deletion). Event batches are retained for retry. Data-delete requests are not retried automatically. |
| `ConsentPersistFailed` | Failed to persist the consent level to disk. The in-memory level still applies but reverts on next launch. |
## FAQ
Both **Mono** and **IL2CPP** are fully supported. Choose either in **Edit → Project Settings → Player → Other Settings → Scripting Backend**.
Yes. Init, Track, session lifecycle, and flush all work in Play Mode and Edit Mode test runners. Events fired in the Editor reach the same pipeline as builds. Set `TestMode = true` in your `AudienceConfig` during development to mark events as test traffic and keep them separate from production data in analytics.
The call is a no-op. No event is queued, no warning is logged. Once `SetConsent(ConsentLevel.Anonymous)` (or higher) runs, subsequent Track calls flow normally. To verify state during development, read `ImmutableAudience.CurrentConsent` or the other [diagnostic getters](#diagnostic-getters).
Yes. The three integrations cover different parts of your player journey. Unity captures in-game actions, the Web SDK covers web games and single-page apps, and the Tracking Pixel handles marketing pages and landing sites. All events flow to the same pipeline. See the [Data Dictionary](/docs/products/audience/data-dictionary) for the full per-event schema.
Erasure is server-side. From your backend, call `DELETE /v1/audience/data` with the user's `userId` or `anonymousId`. See [Deleting User Data](/docs/products/audience/rest-api#deleting-user-data) for the request shape. From inside the game, call `ImmutableAudience.DeleteData(userId)` and then `ImmutableAudience.Reset()` so subsequent activity isn't linked to the erased user.
```csharp theme={null}
await ImmutableAudience.DeleteData("76561190000000000");
ImmutableAudience.Reset();
```
Under `Application.persistentDataPath` in a folder named `imtbl_audience`. The folder contains the anonymous ID, the persisted consent level, and any queued events that haven't been flushed yet. Deleting it resets the SDK's local state on next launch.
Call `Identify` with the same `userId` on both surfaces at login. Events from both sessions are attributed to that player automatically, no `Alias` call needed.
`Alias` is only needed when the same player uses different provider IDs on different surfaces. For example, if the player is identified as a Steam user in-game but later links a Passport account, call `Alias` to tell the backend they are the same person. See [Identity Stitching](/docs/products/audience/data-dictionary#identity-stitching) in the Data Dictionary.
## Next Steps
Working Unity project with Init, consent, Track, and Identify wired up
How tracking data powers player attribution and Hub reports
Full reference of event schemas and consent levels
Passive tracking snippet for marketing sites and landing pages
Typed SDK for web games, marketing sites, and SPAs
Send events from your backend or game server
Send attributed conversions back to ad networks to improve campaign optimisation
Manage your publishable and secret keys
# Web SDK
Source: https://docs.immutable.com/docs/products/audience/web-sdk
Typed tracking SDK for websites and web games. Tracks player progressions, spend, sign-ups, and attribution signals
The Web SDK is currently in **alpha**. APIs and behavior may change between releases.
**Who is this for?** Web developers building web games, marketing sites, or SPAs who need explicit event tracking, user identity, or SPA support. Works with any framework (React, Next.js, Svelte, vanilla JS).
The Immutable Web SDK is a typed package for tracking player behavior as part of the Immutable attribution system. UTM parameters, click IDs, and referrer data are captured automatically, connecting ad campaigns to the players they drove. Session lifecycle (`session_start`, `session_end`) is handled automatically. Page views are tracked by calling `page()` on each route change, and in-game moments like progressions, purchases, and sign-ups are triggered by your code. All events flow to the same pipeline as the [Tracking Pixel](/docs/products/audience/tracking-pixel), [Unity SDK](/docs/products/audience/unity-sdk), and [REST API](/docs/products/audience/rest-api).
## What You Need
* An [Immutable Hub](https://hub.immutable.com) account with a project ([get started here](/docs/products/hub/getting-started))
* A publishable key from your project settings ([API keys guide](/docs/products/hub/api-keys))
## Installation
```bash theme={null}
npm install @imtbl/audience
```
```bash theme={null}
yarn add @imtbl/audience
```
```bash theme={null}
pnpm add @imtbl/audience
```
```html theme={null}
```
The CDN bundle attaches `ImmutableAudience` to `window` with the same API surface: `init`, `AudienceEvents`, `IdentityType`, `canTrack`, `canIdentify`, and `version`.
If your site uses a Content Security Policy, see [Content Security Policy](#content-security-policy) for the directives CDN users need to allow.
## Quick Start
```typescript theme={null}
import { Audience, AudienceEvents, IdentityType } from '@imtbl/audience';
const audience = Audience.init({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
});
```
```html theme={null}
```
Subsequent steps call methods on the `audience` variable. `AudienceEvents` and `IdentityType` are available as `ImmutableAudience.AudienceEvents` and `ImmutableAudience.IdentityType`.
Consent defaults to `'none'`. The SDK won't track anything until you explicitly set a consent level.
The SDK uses a three-tier consent model (`'none'`, `'anonymous'`, `'full'`). The SDK does not provide a consent UI. Build your own cookie banner or privacy prompt, then call `setConsent` with the appropriate level when the user responds.
```typescript theme={null}
// User accepts anonymous tracking
audience.setConsent('anonymous');
// User logs in and accepts full tracking
audience.setConsent('full');
```
At `'anonymous'`, page views and events are tracked but `identify()` and `alias()` calls are dropped. At `'full'`, all methods are available. See the [Data Dictionary](/docs/products/audience/data-dictionary#consent-model) for what is collected at each level.
Call `page()` on each route change. The first call in each session includes [attribution](/docs/products/audience/data-dictionary#attribution-signals) parameters.
```typescript theme={null}
audience.page();
```
Log player actions with `track()`. The SDK ships with [predefined events](/docs/products/audience/data-dictionary#predefined-events) for common player actions (purchases, sign-ups, progression, wishlist actions, and more) that give you typed properties and autocomplete. You can also pass any custom event string.
```typescript theme={null}
// Predefined event — typed properties
audience.track(AudienceEvents.SIGN_UP, { method: 'email' });
audience.track(AudienceEvents.PURCHASE, { currency: 'USD', value: 9.99 });
// Custom event — any properties
audience.track('tutorial_complete', { stepCount: 5 });
```
```javascript theme={null}
// Predefined event — typed properties
audience.track(ImmutableAudience.AudienceEvents.SIGN_UP, { method: 'email' });
audience.track(ImmutableAudience.AudienceEvents.PURCHASE, { currency: 'USD', value: 9.99 });
// Custom event — any properties
audience.track('tutorial_complete', { stepCount: 5 });
```
Identifies the player by associating their userId with their activity and traits. Call at login or account connection. Requires `'full'` consent.
```typescript theme={null}
audience.identify('player-123', IdentityType.Passport, {
email: 'user@example.com',
});
```
```javascript theme={null}
audience.identify('player-123', ImmutableAudience.IdentityType.Passport, {
email: 'user@example.com',
});
```
Flushes the queue and sends a `session_end` event (if consent is above `'none'`).
```typescript theme={null}
audience.shutdown();
```
### Complete Example
```typescript theme={null}
import { Audience, AudienceEvents, IdentityType } from '@imtbl/audience';
// 1. Initialize
const audience = Audience.init({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
});
// 2. Set consent after cookie banner
audience.setConsent('anonymous');
// 3. Track page views on route changes
audience.page();
// 4. Track player actions
audience.track(AudienceEvents.PURCHASE, { currency: 'USD', value: 9.99 });
// 5. Identify after login (requires 'full' consent)
audience.setConsent('full');
audience.identify('player-123', IdentityType.Passport, { email: 'user@example.com' });
// 6. Clean up on exit
audience.shutdown();
```
```html theme={null}
```
## Verify the Integration
Initialize with `debug: true` to see SDK activity in the browser console:
```typescript theme={null}
const audience = Audience.init({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
debug: true,
});
```
1. **Console**: Look for log entries showing `session_start`, `page`, and any `track()` calls
2. **Network tab**: Look for POST requests to `https://api.immutable.com`. Events flush every 5 seconds or when 20 events accumulate
Remove `debug: true` before deploying to production.
## Content Security Policy
If your site enforces a Content-Security-Policy header, add the event endpoint so the SDK can deliver events:
```
connect-src: https://api.immutable.com
```
If you load the SDK from Immutable's CDN, also add the script host:
```
script-src: https://cdn.immutable.com
```
If you install the SDK as a package dependency (npm/yarn/pnpm), only `connect-src` is required. The CDN directive is only needed for the `
```
## API Reference
Creates and returns an `Audience` instance. Call once when your app loads to set up tracking.
| Parameter | Type | Required | Default | Description |
| ---------------- | --------------------------------- | -------- | -------------- | --------------------------------------------------------------------------------------------------------------- |
| `publishableKey` | `string` | Yes | — | API key from [Hub](https://hub.immutable.com) |
| `consent` | `'none' \| 'anonymous' \| 'full'` | No | `'none'` | Initial consent level |
| `debug` | `boolean` | No | `false` | Log SDK activity to the browser console |
| `testMode` | `boolean` | No | `false` | When `true`, stamps all events with `test: true` so they can be filtered from production analytics. |
| `cookieDomain` | `string` | No | Current domain | Share cookies across subdomains (e.g. `'.studio.com'`). The Tracking Pixel uses `domain` for this same setting. |
| `flushInterval` | `number` | No | `5000` | How often the queue flushes, in milliseconds |
| `flushSize` | `number` | No | `20` | Number of queued messages that triggers a flush |
| `baseUrl` | `string` | No | Immutable API | Override the default API base URL |
| `onError` | `(err: AudienceError) => void` | No | `undefined` | Callback for flush and consent sync failures |
```typescript theme={null}
const audience = Audience.init({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
consent: 'none',
debug: true,
cookieDomain: '.studio.com',
onError: (err) => console.error(err.code, err.message),
});
```
Controls what the SDK is allowed to collect. Call when the user accepts or changes their cookie preferences. Takes effect immediately, no page reload needed. See the [Data Dictionary](/docs/products/audience/data-dictionary#consent-model) for consent levels and [downgrade behavior](/docs/products/audience/data-dictionary#downgrading-consent).
```typescript theme={null}
// Upgrade after cookie banner
audience.setConsent('anonymous');
// Downgrade — stops tracking, clears cookies
audience.setConsent('none');
```
Checks whether a consent level allows tracking. Returns `true` when `level` is `'anonymous'` or `'full'`. Use before calling `track()` or `page()` when you need to guard behavior based on the current consent level, for example when conditionally showing a tracking-dependent UI element.
| Parameter | Type | Description |
| --------- | --------------------------------- | -------------------------- |
| `level` | `'none' \| 'anonymous' \| 'full'` | The consent level to check |
```typescript theme={null}
import { canTrack } from '@imtbl/audience';
if (canTrack(currentConsentLevel)) {
// safe to call track() and page()
}
```
Checks whether a consent level allows player identification. Returns `true` when `level` is `'full'`. Use before calling `identify()` or `alias()` when you need to guard identity-dependent logic, for example to only prompt a player to link accounts when consent allows it.
| Parameter | Type | Description |
| --------- | --------------------------------- | -------------------------- |
| `level` | `'none' \| 'anonymous' \| 'full'` | The consent level to check |
```typescript theme={null}
import { canIdentify } from '@imtbl/audience';
if (canIdentify(currentConsentLevel)) {
// safe to call identify() and alias()
}
```
Records a page view. Call on every route change to track which pages players visit and in what order. The first call in each session includes [attribution](/docs/products/audience/data-dictionary#attribution-signals) parameters.
| Parameter | Type | Description |
| ------------ | ------------------------- | -------------------------- |
| `properties` | `Record` | Optional custom properties |
**Requires:** `'anonymous'` or `'full'` consent. Calls at `'none'` are silently dropped.
Records a player action. Call when a player does something you want to measure, such as a purchase, sign-up, level completion, or any custom action. [Predefined events](/docs/products/audience/data-dictionary#predefined-events) give you typed properties with autocomplete.
| Parameter | Type | Description |
| ------------ | ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `event` | `string` | An `AudienceEvents` value (e.g. `AudienceEvents.PURCHASE`) or any custom string |
| `properties` | `Record` | Event properties. Required for predefined events that have required fields (e.g. `purchase`), optional otherwise. |
**Requires:** `'anonymous'` or `'full'` consent. Calls at `'none'` are silently dropped.
```typescript theme={null}
// Predefined event — typed properties
audience.track(AudienceEvents.PURCHASE, { currency: 'USD', value: 9.99, itemName: 'Sword' });
// Custom event — any properties
audience.track('tutorial_complete', { stepCount: 5 });
```
Associates the player's userId with their activity and traits. Call at login or account connection.
| Parameter | Type | Description |
| -------------- | -------------- | ---------------------------------------------------- |
| `id` | `string` | The player's identifier in that provider |
| `identityType` | `IdentityType` | Which provider the ID comes from (see table below) |
| `traits` | `object` | Optional. `email`, `name`, or custom key-value pairs |
**Requires:** `'full'` consent.
```typescript theme={null}
audience.identify('player-456', IdentityType.Passport, {
email: 'user@example.com',
});
```
**Identity types:**
| Value | Provider |
| ------------ | ---------------------- |
| `'passport'` | Immutable Passport |
| `'steam'` | Steam |
| `'epic'` | Epic Games |
| `'google'` | Google |
| `'apple'` | Apple |
| `'discord'` | Discord |
| `'email'` | Email address |
| `'custom'` | Custom identity system |
Links two account IDs that belong to the same player. Call when a player connects a second account with a different provider, for example a player previously identified via Steam who later links a Passport account.
| Parameter | Type | Description |
| --------- | -------------------------------------------- | ------------------------- |
| `from` | `{ id: string, identityType: IdentityType }` | The account being linked |
| `to` | `{ id: string, identityType: IdentityType }` | The player's main account |
**Requires:** `'full'` consent.
```typescript theme={null}
audience.alias(
{ id: 'steam-user-789', identityType: IdentityType.Steam },
{ id: 'player-456', identityType: IdentityType.Passport },
);
```
Wipes the current player's identity and starts a fresh anonymous session. Call when a player logs out so the next player on the same device isn't mixed up.
```typescript theme={null}
audience.reset();
```
Sends all queued events to the server immediately. Call when you need events delivered right now instead of waiting for the next automatic flush. Returns a `Promise` that resolves when the batch is sent.
```typescript theme={null}
await audience.flush();
```
Sends any remaining events and shuts down the SDK. Call when the app unmounts or the page is about to unload. Fires a `session_end` event if consent is above `'none'`.
## Event Delivery
* Events are batched and flushed every **5 seconds** or when **20 events** accumulate (configurable via `flushInterval` and `flushSize`)
* On page unload (navigate away, close tab), the queue flushes remaining events so they are not lost
## FAQ
Yes, they're complementary, not alternatives. The [Tracking Pixel](/docs/products/audience/tracking-pixel) handles passive capture (page views, attribution, form submissions, outbound clicks) with no code beyond a snippet. The Web SDK handles explicit events, user identity, and SPA route tracking. Use both on the same site if you need passive and custom tracking. All events flow to the same pipeline. See the [Data Dictionary](/docs/products/audience/data-dictionary) for the full per-event schema.
The call is a no-op. No events are queued or sent. Once you call `setConsent('anonymous')` or higher, subsequent calls will be tracked.
Chrome 80+, Firefox 78+, Safari 14+, Edge 80+.
Erasure is a server-side operation. Call `DELETE /v1/audience/data` from your backend with the user's `userId` or `anonymousId`. See [Deleting User Data](/docs/products/audience/rest-api#deleting-user-data) in the REST API docs for the full request shape.
On the client side, call `reset()` after the erasure is accepted. This clears the user's identity, wipes the event queue, and generates a fresh anonymous ID so the next session is not linked to the erased user:
```typescript theme={null}
audience.reset();
```
## Next Steps
How tracking data powers player attribution and Hub reports
Full reference of event schemas and consent levels
Passive tracking snippet for marketing sites and landing pages
In-game tracking for Unity desktop builds
Send events from your backend or game server
Send attributed conversions back to ad networks to improve campaign optimisation
Manage your publishable and secret keys
# Bridge
Source: https://docs.immutable.com/docs/products/checkout/bridge
# Bridge
Move tokens from Ethereum mainnet to Immutable Chain.
**When to use:**
* Bringing ETH or ERC-20 tokens to Immutable Chain
* Players have assets on Ethereum they want to use in-game
* L1 to L2 asset migration
***
## Overview
The bridge widget enables players to transfer tokens between Ethereum L1 and Immutable zkEVM L2. The widget uses different bridge providers based on optimal routes:
The widget automatically selects the best bridge provider based on the token being transferred and current network conditions.
**Token Allowlisting Required:** Only verified/allowlisted ERC-20 tokens will display on the swap and bridge widgets. Tokens must complete the Asset Verification procedure before they can be swapped or bridged.
Contact [Developer Support](https://support.immutable.com/) for allowlisting inquiries and token standards information.
***
## Prerequisites
Install the Immutable SDK:
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
## Quick Start
```typescript theme={null}
import { checkout } from '@imtbl/sdk';
import { Environment } from '@imtbl/sdk/config';
const { Checkout, WidgetType, WidgetTheme } = checkout;
const checkoutSDK = new Checkout({
baseConfig: {
environment: Environment.SANDBOX, // or Environment.PRODUCTION
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
const { CommerceFlowType, CommerceEventType } = checkout;
// Basic bridge flow
export async function openBridge(elementId: string) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, { flow: CommerceFlowType.BRIDGE });
widget.addListener(CommerceEventType.SUCCESS, (data) => {
console.log('Bridge initiated:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.FAILURE, (data) => {
console.error('Bridge failed:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.CLOSE, () => {
widget.unmount();
});
return widget;
}
// Bridge with pre-filled token and amount
export async function bridgeWithAmount(
elementId: string,
options: {
tokenAddress: string;
amount: string;
}
) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, {
flow: CommerceFlowType.BRIDGE,
tokenAddress: options.tokenAddress,
amount: options.amount,
});
return widget;
}
```
### Parameters
| Parameter | Type | Description |
| -------------- | ---------- | -------------------------------------------- |
| `flow` | `'BRIDGE'` | Required. Specifies the bridge flow |
| `tokenAddress` | `string` | Optional. Token to bridge (Ethereum address) |
| `amount` | `string` | Optional. Amount to bridge |
### Events
| Event | Description | Payload |
| --------- | ------------------ | --------------------------------------------------------------- |
| `SUCCESS` | Bridge initiated | `{ transactionHash, fromToken, toToken, fromAmount, toAmount }` |
| `FAILURE` | Bridge failed | `{ error }` |
| `CLOSE` | User closed widget | — |
***
## Next Steps
Exchange tokens after bridging
Buy crypto with fiat
Sell NFTs to players
Complete marketplace tutorial
# Connect
Source: https://docs.immutable.com/docs/products/checkout/connect
Connect Passport, MetaMask, WalletConnect, and other EIP-1193 wallets.
**When to use:**
* Initial user authentication
* Connecting wallets before transactions
* Switching between wallets
## Installation
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
## Quick Start
```typescript theme={null}
import { checkout } from '@imtbl/sdk';
import { Environment } from '@imtbl/sdk/config';
const { Checkout, WidgetType, WidgetTheme } = checkout;
const checkoutSDK = new Checkout({
baseConfig: {
environment: Environment.SANDBOX, // or Environment.PRODUCTION
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
const { CommerceFlowType, CommerceEventType } = checkout;
// Basic connect flow
export async function connectWallet(elementId: string) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, { flow: CommerceFlowType.CONNECT });
widget.addListener(CommerceEventType.SUCCESS, async (data) => {
console.log('Connected:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.CLOSE, () => {
widget.unmount();
});
widget.addListener(CommerceEventType.FAILURE, (data) => {
console.error('Connection failed:', data);
widget.unmount();
});
return widget;
}
// Wallet RDNS identifiers
export const WALLET_RDNS = {
passport: 'com.immutable.passport',
metamask: 'io.metamask',
coinbase: 'com.coinbase.wallet',
} as const;
```
## Parameters
| Parameter | Type | Description |
| --------------------- | ----------- | ---------------------------------------- |
| `flow` | `'CONNECT'` | Required. Specifies the connect flow |
| `targetWalletRdns` | `string` | Optional. Target specific wallet by RDNS |
| `targetChainId` | `string` | Optional. Target chain to connect to |
| `blocklistWalletRdns` | `string[]` | Optional. Wallets to hide from selection |
## Events
| Event | Description | Payload |
| --------- | ------------------ | ---------------------------------- |
| `SUCCESS` | Wallet connected | `{ walletProviderName, provider }` |
| `FAILURE` | Connection failed | `{ error }` |
| `CLOSE` | User closed widget | — |
## Supported Wallets
| Wallet | RDNS | Notes |
| --------------- | ------------------------ | --------------------- |
| Passport | `com.immutable.passport` | Recommended for games |
| MetaMask | `io.metamask` | Browser extension |
| WalletConnect | — | Mobile wallets |
| Coinbase Wallet | `com.coinbase.wallet` | Browser + mobile |
Any EIP-1193 compatible wallet injected into the browser will appear automatically.
## Next Steps
Show balances after connection
Learn about Passport authentication
# Fund
Source: https://docs.immutable.com/docs/products/checkout/fund
Guide users to add specific tokens to their wallet, combining swap, bridge, and onramp options in one interface.
**When to use:**
* User needs a specific token for a purchase
* Pre-transaction funding when balance is insufficient
* Helping users get the right currency for your game
## Installation
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
## Quick Start
```typescript theme={null}
import { checkout } from '@imtbl/sdk';
import { Environment } from '@imtbl/sdk/config';
const { Checkout, WidgetType, WidgetTheme } = checkout;
const checkoutSDK = new Checkout({
baseConfig: {
environment: Environment.SANDBOX, // or Environment.PRODUCTION
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
const { CommerceFlowType, CommerceEventType } = checkout;
// Fund flow - get specific tokens via swap, bridge, or onramp
export async function fundToken(
elementId: string,
options: {
toTokenAddress: string;
toAmount: string;
}
) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, {
flow: CommerceFlowType.ADD_TOKENS,
toTokenAddress: options.toTokenAddress,
toAmount: options.toAmount,
});
widget.addListener(CommerceEventType.SUCCESS, (data) => {
console.log('Fund complete:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.FAILURE, (data) => {
console.error('Fund failed:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.CLOSE, () => {
widget.unmount();
});
return widget;
}
```
## Parameters
| Parameter | Type | Description |
| ---------------- | -------------- | --------------------------------- |
| `flow` | `'ADD_TOKENS'` | Required. Specifies the fund flow |
| `toTokenAddress` | `string` | Target token to acquire |
| `toAmount` | `string` | Amount of target token needed |
| `showBackButton` | `boolean` | Show back button |
## How It Works
The Fund flow analyzes the user's current balances and presents the best options:
```
User needs 100 USDC
↓
Check wallet balances
↓
┌─────────────────────────────────────────┐
│ Options presented to user: │
│ • Swap 150 IMX → 100 USDC (if has IMX) │
│ • Bridge USDC from Ethereum (if has) │
│ • Buy 100 USDC with card │
└─────────────────────────────────────────┘
↓
User completes preferred flow
```
## Events
| Event | Description | Payload |
| --------- | ------------------ | --------------------------- |
| `SUCCESS` | Tokens acquired | `{ method, token, amount }` |
| `FAILURE` | Acquisition failed | `{ error }` |
| `CLOSE` | User closed widget | — |
The `method` field indicates how tokens were acquired: `'swap'`, `'bridge'`, or `'onramp'`.
## Use Case: Insufficient Balance
A common pattern is triggering the Fund flow when a user tries to make a purchase but lacks sufficient tokens. See the `PurchaseWithFunding` component in the code snippets above.
## Next Steps
Combine funding with NFT purchases
Direct token swaps
# Onramp
Source: https://docs.immutable.com/docs/products/checkout/onramp
# Onramp
Buy crypto with credit card, Apple Pay, or Google Pay.
**When to use:**
* New players without existing crypto
* Topping up wallets with fiat in web, mobile, or desktop games
* In-game purchases where players need to buy IMX, USDC, or ETH
* Users who want to fund their zkEVM wallets using fiat currency (e.g., EUR, USD)
***
## Overview
The [Transak](https://transak.com/) widget enables token on-ramping, allowing players to purchase tokens with fiat currency (e.g., USD, EUR, GBP) using credit card, Apple Pay, or Google Pay.
Transak is a leading fiat-to-crypto payment gateway that handles regulatory compliance, payment processing, and user verification.
***
## Prerequisites
Install the Immutable SDK:
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
## Quick Start
```typescript theme={null}
import { checkout } from '@imtbl/sdk';
import { Environment } from '@imtbl/sdk/config';
const { Checkout, WidgetType, WidgetTheme } = checkout;
const checkoutSDK = new Checkout({
baseConfig: {
environment: Environment.SANDBOX, // or Environment.PRODUCTION
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
const { CommerceFlowType, CommerceEventType } = checkout;
// Basic onramp flow - buy crypto with fiat
export async function openOnramp(elementId: string) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, { flow: CommerceFlowType.ONRAMP });
widget.addListener(CommerceEventType.SUCCESS, (data) => {
console.log('Onramp complete:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.FAILURE, (data) => {
console.error('Onramp failed:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.CLOSE, () => {
widget.unmount();
});
return widget;
}
// Onramp with wallet address pre-filled
export async function onrampToWallet(
elementId: string,
walletAddress: string
) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, {
flow: CommerceFlowType.ONRAMP,
walletAddress,
});
return widget;
}
```
### Parameters
| Parameter | Type | Description |
| --------------- | ---------- | ------------------------------------- |
| `flow` | `'ONRAMP'` | Required. Specifies the onramp flow |
| `walletAddress` | `string` | Optional. Pre-fill the wallet address |
### Events
| Event | Description | Payload |
| --------- | ------------------ | -------------------------- |
| `SUCCESS` | Onramp completed | `{ transactionHash, ... }` |
| `FAILURE` | Onramp failed | `{ error }` |
| `CLOSE` | User closed widget | — |
***
## Next Steps
Exchange purchased tokens
Move tokens from Ethereum
Sell NFTs to players
Complete marketplace tutorial
# Checkout
Source: https://docs.immutable.com/docs/products/checkout/overview
Drop-in widgets for wallet connection, token swaps, bridging, fiat onramps, and primary sales.
View a complete working example
## Why Use Checkout?
Mount pre-built widgets instead of building complex transaction UIs. Handle swaps, bridges, and payments with a few lines of code.
Users don't need to understand gas, bridges, or DEXs. The widget guides them through each flow with clear UI.
Match your app's theme with built-in dark/light modes and language localization.
Works with Passport, MetaMask, WalletConnect, and any EIP-1193 compatible wallet.
**Prerequisites:** Checkout requires wallet authentication. We recommend [Passport](/docs/products/passport/overview) for seamless wallet creation and login, but you can also use MetaMask, WalletConnect, or any EIP-1193 compatible wallet.
## Wallet Funding: Which Flow to Use?
Choose the right funding method based on your users' starting point:
| User Has | Best Flow | Why |
| ---------------------- | ---------------------------------------- | -------------------------------------------------- |
| No crypto | [Onramp](/docs/products/checkout/onramp) | Buy crypto with credit card, Apple Pay, Google Pay |
| Funds on Ethereum L1 | [Bridge](/docs/products/checkout/bridge) | Transfer tokens from Ethereum mainnet to zkEVM |
| Wrong token on zkEVM | [Swap](/docs/products/checkout/swap) | Exchange tokens on Immutable Chain via QuickSwap |
| Funds on another chain | [Fund](/docs/products/checkout/fund) | Smart routing via swap/bridge/onramp |
**Need help choosing?** See individual flow pages for detailed "when to use" guidance and platform-specific examples (TypeScript, Unity, Unreal).
## Available Flows
| Flow | Description | Platforms |
| ------------------------------------------ | --------------------------------------------------- | ------------------------- |
| [Connect](/docs/products/checkout/connect) | Connect wallets (Passport, MetaMask, WalletConnect) | TypeScript |
| [Wallet](/docs/products/checkout/wallet) | View balances, access other flows | TypeScript |
| [Onramp](/docs/products/checkout/onramp) | Buy crypto with fiat currency | TypeScript, Unity, Unreal |
| [Swap](/docs/products/checkout/swap) | Exchange tokens on Immutable Chain | TypeScript, Unity, Unreal |
| [Bridge](/docs/products/checkout/bridge) | Move tokens between Ethereum and Immutable Chain | TypeScript, Unity, Unreal |
| [Fund](/docs/products/checkout/fund) | Smart token acquisition with automatic routing | TypeScript |
| [Sale](/docs/products/checkout/sale) | Sell NFTs with integrated payment handling | TypeScript |
## Platform-Specific Implementation
**Full widget integration** - Mount widgets directly in your web application with React/Vue/vanilla JS.
See individual flow pages ([Onramp](/docs/products/checkout/onramp), [Swap](/docs/products/checkout/swap), [Bridge](/docs/products/checkout/bridge)) for complete TypeScript examples with installation, code, and configuration.
**URL generation** - Generate URLs and open in system browser for security and compatibility.
**Setup:** [Unity SDK – Marketplace package](/docs/sdks/unity/overview)
**Implementation:** Each flow page has a dedicated Unity tab with C# examples and configuration options.
**URL generation** - Generate URLs and open in system browser for security and compatibility.
**Setup:** [Unreal SDK](/docs/sdks/unreal/overview)
**Implementation:** Each flow page has a dedicated Unreal tab with C++ examples, Blueprint guidance, and configuration options.
## TypeScript Quick Start
### Installation
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
### Initialize Checkout
```typescript theme={null}
import { checkout } from '@imtbl/sdk';
import { Environment } from '@imtbl/sdk/config';
const { Checkout, WidgetType, WidgetTheme } = checkout;
const checkoutSDK = new Checkout({
baseConfig: {
environment: Environment.SANDBOX, // or Environment.PRODUCTION
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
// Create widgets factory
const widgets = await checkoutSDK.widgets({
config: {
theme: WidgetTheme.DARK, // or LIGHT
},
});
// Create and mount a widget
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount('widget-root', {
flow: 'ONRAMP' // or 'SWAP', 'BRIDGE', 'SALE', etc.
});
```
For complete examples with event handling and configuration options, see individual flow pages.
## Configuration Options
### Widget Configuration
| Option | Values | Description |
| ---------- | ------------------------------ | --------------------------- |
| `theme` | `'dark'`, `'light'` | Widget color theme |
| `language` | `'en'`, `'ja'`, `'ko'`, `'zh'` | UI language |
| `version` | `{ major, minor, patch }` | Pin to specific SDK version |
### Widget Methods
All commerce widgets share these methods:
| Method | Description |
| ------------------------------ | --------------------------- |
| `mount(elementId, params)` | Mount widget to DOM element |
| `update(config)` | Update widget configuration |
| `addListener(event, callback)` | Listen to events |
| `removeListener(event)` | Remove event listener |
| `unmount()` | Clean up widget |
### Common Events
All flows emit these events:
| Event | Description | Payload |
| ------------------ | --------------------------- | ------------------ |
| `INITIALISED` | Widget loaded and ready | — |
| `CLOSE` | User clicked close button | — |
| `SUCCESS` | Flow completed successfully | Flow-specific data |
| `FAILURE` | Error occurred | `{ error }` |
| `PROVIDER_UPDATED` | Wallet provider changed | `{ provider }` |
## Integration Guides
Complete marketplace with wallet funding
Start with game basics first
Wallet creation and authentication
Setup Hub credentials
## Next Steps
Buy crypto with fiat
Exchange tokens
Transfer from L1
Sell NFTs to players
Unity implementation
Unreal implementation
# Primary Sales
Source: https://docs.immutable.com/docs/products/checkout/primary-sales
Sell NFTs with payment collection, order verification, and minting—all in a single widget.
**When to use:**
* NFT drops and launches
* In-game item purchases
* Digital collectible storefronts
## How it works
```
User selects items → Widget handles payment → Your backend verifies → NFT minted to wallet
```
The widget handles payment collection (crypto + fiat), wallet connection, and insufficient balance flows. Your backend handles order creation, payment verification, and NFT minting.
## Prerequisites
* [Hub project](/docs/products/hub/getting-started) with an environment
* [ERC-721](/docs/products/asset-contracts/erc721) or [ERC-1155](/docs/products/asset-contracts/erc1155) collection deployed
## Setup
Go to [Hub](https://hub.immutable.com) → **Primary Sales** to deploy a Primary Sales contract, configure payment currencies and payout addresses, and choose between Simplified or Advanced mode.
Mount the Sale widget in your frontend to handle the checkout flow.
Implement webhook endpoints if using Advanced mode for dynamic pricing or custom logic.
## Widget integration
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
```typescript theme={null}
import { checkout } from '@imtbl/sdk';
import { Environment } from '@imtbl/sdk/config';
const { Checkout, WidgetType, WidgetTheme } = checkout;
const checkoutSDK = new Checkout({
baseConfig: {
environment: Environment.SANDBOX, // or Environment.PRODUCTION
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
const { CommerceFlowType, CommerceEventType } = checkout;
export async function openSale(
elementId: string,
items: checkout.SaleItem[],
environmentId: string
) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, {
flow: CommerceFlowType.SALE,
items,
environmentId,
});
widget.addListener(CommerceEventType.SUCCESS, (data) => {
console.log('Purchase complete:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.FAILURE, (data) => {
console.error('Purchase failed:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.CLOSE, () => {
widget.unmount();
});
return widget;
}
// Example items
const items: checkout.SaleItem[] = [
{
productId: 'starter-pack',
name: 'Starter Pack',
description: '10 cards + 500 gold',
image: 'https://example.com/pack.png',
qty: 1,
},
];
```
### Parameters
| Parameter | Type | Description |
| --------------------- | ------------ | ---------------------------------- |
| `flow` | `'SALE'` | Required. Specifies the sale flow. |
| `items` | `SaleItem[]` | Items to purchase. |
| `environmentId` | `string` | Your Hub environment ID. |
| `collectionName` | `string` | Collection name (display only). |
| `excludePaymentTypes` | `string[]` | Payment methods to hide. |
### Events
| Event | Description |
| --------- | ---------------------------------------------------------- |
| `SUCCESS` | Purchase completed. Payload: `{ transactionHash, tokens }` |
| `FAILURE` | Purchase failed. Payload: `{ error }` |
| `CLOSE` | User closed widget. |
## Sale modes
Create and manage products directly in Hub—no backend required.
* Products created in Hub are automatically available for sale
* Stock management handled by the widget
* Token IDs auto-generated for ERC-721, specified for ERC-1155
**Products API**
**List products (read)**
Storefronts and backends can fetch the catalog without authentication:
```
# Testnet
GET https://api.sandbox.immutable.com/v1/primary-sales/:environmentId/products
# Mainnet
GET https://api.immutable.com/v1/primary-sales/:environmentId/products
```
**Create or update a product**
Upserts a single product by ID (pricing, stock, metadata, linked collection and token rules, etc.). Same URL for create and update.
```
# Testnet
PUT https://api.sandbox.immutable.com/v1/primary-sales/:environmentId/product
# Mainnet
PUT https://api.immutable.com/v1/primary-sales/:environmentId/product
```
`PUT` **replaces** the entire product record for that `product_id`. Send a JSON object with the shape below. A successful response returns the persisted product (same fields as **GET** `/products`).
**Product**
| Field | Type | Required | Description |
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------- |
| `product_id` | string | Yes | Stable catalog identifier (for example `starter-pack`). |
| `name` | string | Yes | Display name. |
| `image` | string | Yes | URL for product art or icon. |
| `description` | string | No | Longer description shown in UIs that support it. |
| `status` | string | Yes | `active` or `inactive`. Inactive products cannot be purchased in **Simplified** mode. |
| `quantity` | integer | Yes | Stock available for sale (inventory). |
| `pricing` | array | Yes | One or more price rows (see **Pricing** below). At least one entry is required. |
| `collection` | object | Yes | Which collection and token rules apply at mint (see **Collection** below). |
| `limits` | object | No | Optional per-buyer caps (see **Limits** below). |
**Pricing** (`pricing[]`)
Each element defines a price in one currency:
| Field | Type | Required | Description |
| ---------- | ------ | -------- | ----------------------------------------------------------------------------------------- |
| `currency` | string | Yes | Currency code (for example `USDC`), aligned with currencies configured for Primary Sales. |
| `amount` | number | Yes | Unit price in that currency for a single item. |
**Collection** (`collection`)
| Field | Type | Required | Description |
| -------------------- | ------ | ------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `collection_type` | string | Yes | `ERC721` or `ERC1155`. Must match how the linked collection was deployed. |
| `collection_address` | string | Yes | NFT collection contract on chain (same collection linked to Primary Sales in Hub). |
| `token_id` | string | ERC-1155 only | Fixed token ID for this product. **Required** for ERC-1155. Omit or leave unset for ERC-721 (mints use generated IDs). |
**Limits** (`limits`)
| Field | Type | Required | Description |
| --------------------------- | ------- | ----------------------- | ----------------------------------------------------------------------------------------- |
| `enabled` | boolean | Yes if `limits` is sent | When `true`, per-recipient limits apply. |
| `max_per_recipient_address` | integer | If enabled | Maximum units one wallet may purchase; must be greater than zero when limits are enabled. |
**Example (ERC-721)**
```json theme={null}
{
"product_id": "starter-pack",
"name": "Starter Pack",
"description": "10 cards + 500 gold",
"image": "https://example.com/pack.png",
"status": "active",
"quantity": 500,
"pricing": [{ "currency": "USDC", "amount": 9.99 }],
"collection": {
"collection_type": "ERC721",
"collection_address": "0x0000000000000000000000000000000000000000"
},
"limits": { "enabled": false }
}
```
Use your real collection address from Hub in place of the placeholder `collection_address`.
**Delete a product**
Removes a product from the catalog for that environment.
```
# Testnet
DELETE https://api.sandbox.immutable.com/v1/primary-sales/:environmentId/product/:productId
# Mainnet
DELETE https://api.immutable.com/v1/primary-sales/:environmentId/product/:productId
```
**Authentication**
Listing products is public; **create, update, and delete require authentication**. Authentication requires either:
| Method | Header | When to use |
| ------------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **Bearer token** | `Authorization: Bearer ` | Used when managing the product catalogue via Hub |
| **Secret API key** | `x-immutable-api-key: ` | Server-side scripts, automation, or backends using a [secret API key](/docs/products/hub/api-keys) issued for your project. |
Navigate to the Settings page of the project environment to create a secret API key. Do not expose secret keys in client-side code. Only manage products from your backend or Hub.
**Metadata for minted tokens:**
* **ERC-1155**: Pre-define metadata for the Token ID before sales begin
* **ERC-721**: Set up a [webhook in Hub](/docs/products/hub/webhooks) for `imtbl_zkevm_activity_mint` events, then generate metadata and call the [Metadata Refresh API](/docs/products/indexer/overview)
Implement webhook endpoints for full control over pricing, stock, and order fulfillment.
**When to use:**
* Dynamic pricing based on user or market conditions
* Complex inventory management
* Custom eligibility rules
* Integration with existing e-commerce systems
Configure your webhook base URL in Hub. The widget calls your backend at four lifecycle stages:
```
User selects items → Quote → User reviews → Authorize → Payment executes → Confirm
↓
(if payment fails) Expired
```
## Webhooks (Advanced mode)
Called when a user requests a price quote for items.
**Endpoint:** `POST {baseUrl}/quote`
**Request:**
```json theme={null}
{
"recipient_address": "0x...",
"products": [
{ "product_id": "starter-pack", "quantity": 1 }
],
"currency": "USDC"
}
```
**Response:**
```json theme={null}
{
"products": [
{
"product_id": "starter-pack",
"quantity": 1,
"pricing": [
{
"currency": "USDC",
"currency_type": "ERC20",
"amount": "10000000",
"contract_address": "0x..."
}
]
}
],
"totals": {
"subtotal": "10000000",
"fees": "0",
"total": "10000000"
},
"currency": "USDC",
"expires_at": "2024-01-01T12:00:00Z"
}
```
```typescript theme={null}
app.post('/quote', async (req, res) => {
const { recipient_address, products, currency } = req.body;
// Validate products exist and are in stock
const validatedProducts = await validateProducts(products);
// Calculate pricing (can be dynamic based on user, time, etc.)
const pricing = await calculatePricing(validatedProducts, currency);
// Set expiry (e.g., 5 minutes)
const expires_at = new Date(Date.now() + 5 * 60 * 1000).toISOString();
res.json({
products: pricing.products,
totals: pricing.totals,
currency,
expires_at,
});
});
```
Called when the user confirms the order and is ready to pay. Reserve inventory here.
**Endpoint:** `POST {baseUrl}/authorize`
**Request:**
```json theme={null}
{
"reference": "order-123",
"recipient_address": "0x...",
"products": [
{
"product_id": "starter-pack",
"quantity": 1,
"collection_address": "0x...",
"token_ids": ["12345"]
}
],
"currency": "USDC",
"total_amount": "10000000"
}
```
**Response:**
```json theme={null}
{
"reference": "order-123",
"authorized": true
}
```
```typescript theme={null}
app.post('/authorize', async (req, res) => {
const { reference, recipient_address, products, total_amount } = req.body;
try {
// Reserve inventory
await reserveInventory(reference, products);
// Store order for later confirmation
await createPendingOrder({
reference,
recipient_address,
products,
total_amount,
status: 'pending',
});
res.json({ reference, authorized: true });
} catch (error) {
res.json({ reference, authorized: false, reason: error.message });
}
});
```
Called after successful on-chain payment. Mint the NFTs here.
**Endpoint:** `POST {baseUrl}/confirm`
**Request:**
```json theme={null}
{
"reference": "order-123",
"recipient_address": "0x...",
"tx_hash": "0x...",
"products": [
{
"product_id": "starter-pack",
"quantity": 1,
"collection_address": "0x...",
"token_ids": ["12345"]
}
]
}
```
**Response:**
```json theme={null}
{
"reference": "order-123",
"confirmed": true
}
```
```typescript theme={null}
app.post('/confirm', async (req, res) => {
const { reference, recipient_address, tx_hash, products } = req.body;
try {
// Verify the transaction on-chain
const tx = await provider.getTransaction(tx_hash);
if (!tx || tx.to !== PRIMARY_SALES_CONTRACT) {
throw new Error('Invalid transaction');
}
await tx.wait(1);
// Mint NFTs via Minting API
for (const product of products) {
for (const tokenId of product.token_ids) {
await mintNFT({
recipient: recipient_address,
contractAddress: product.collection_address,
tokenId,
metadata: await getMetadata(product.product_id),
});
}
}
// Update order status
await updateOrderStatus(reference, 'completed');
res.json({ reference, confirmed: true });
} catch (error) {
res.json({ reference, confirmed: false, reason: error.message });
}
});
```
Called when an authorized order expires without payment. Release reserved inventory.
**Endpoint:** `POST {baseUrl}/expired`
**Request:**
```json theme={null}
{
"reference": "order-123",
"recipient_address": "0x...",
"products": [
{ "product_id": "starter-pack", "quantity": 1 }
]
}
```
**Response:**
```json theme={null}
{
"reference": "order-123",
"expired": true
}
```
```typescript theme={null}
app.post('/expired', async (req, res) => {
const { reference, products } = req.body;
// Release reserved inventory
await releaseInventory(reference, products);
// Update order status
await updateOrderStatus(reference, 'expired');
res.json({ reference, expired: true });
});
```
### Webhook security
Validate incoming webhooks to ensure they're from Immutable:
```typescript theme={null}
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// In your webhook handler
app.post('/webhooks/immutable', (req, res) => {
const signature = req.headers['x-immutable-signature'];
const isValid = verifyWebhookSignature(
JSON.stringify(req.body),
signature,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process the webhook
handleWebhookEvent(req.body);
res.status(200).send('OK');
});
```
## Payment options
### Crypto
Users can pay with tokens on Immutable Chain. Default currencies are USDC and IMX. Custom ERC-20 tokens must be whitelisted.
If users don't have enough tokens, the widget offers [swap](/docs/products/checkout/swap), [bridge](/docs/products/checkout/bridge), or [fiat onramp](/docs/products/checkout/onramp) options.
### Fiat
Users can pay with credit card, Apple Pay, or Google Pay. Funds settle to your wallet in USDC. KYC may be required for larger amounts.
## Limits
Maximum **350 items** per transaction due to gas limits. For larger orders, split into multiple transactions.
## Next steps
Mint NFTs from your backend
Configure webhooks for mint events
Deploy NFT collections
Enable secondary trading
# Swap
Source: https://docs.immutable.com/docs/products/checkout/swap
# Swap
Exchange tokens on Immutable Chain via QuickSwap.
**When to use:**
* Players need a specific token for in-game purchases
* Converting between game currencies
* Exchanging tokens before marketplace transactions
***
## Overview
The swap widget, powered by [QuickSwap](https://quickswap.exchange/), facilitates token exchanges on Immutable zkEVM. QuickSwap is a decentralized exchange (DEX) that provides liquidity for token swaps on Immutable Chain.
Any allowlisted token with liquidity on QuickSwap is supported. The widget automatically routes swaps through the most efficient liquidity pools to minimize slippage and transaction costs.
**Token Allowlisting Required:** Only verified/allowlisted ERC-20 tokens will display on the swap and bridge widgets. Tokens must complete the Asset Verification procedure before they can be swapped or bridged.
Contact [Developer Support](https://support.immutable.com/) for allowlisting inquiries and token standards information.
***
## Prerequisites
Install the Immutable SDK:
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
## Quick Start
```typescript theme={null}
import { checkout } from '@imtbl/sdk';
import { Environment } from '@imtbl/sdk/config';
const { Checkout, WidgetType, WidgetTheme } = checkout;
const checkoutSDK = new Checkout({
baseConfig: {
environment: Environment.SANDBOX, // or Environment.PRODUCTION
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
const { CommerceFlowType, CommerceEventType } = checkout;
// Basic swap: swap to USDC
export async function openSwap(elementId: string) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, {
flow: CommerceFlowType.SWAP,
toTokenAddress: '0x3B2d8A1931736Fc321C24864BceEe981B11c3c57', // USDC testnet
});
widget.addListener(CommerceEventType.SUCCESS, (data) => {
console.log('Swap complete:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.FAILURE, (data) => {
console.error('Swap failed:', data);
widget.unmount();
});
widget.addListener(CommerceEventType.CLOSE, () => {
widget.unmount();
});
return widget;
}
// Swap with pre-filled values
export async function swapWithAmount(
elementId: string,
options: {
fromTokenAddress?: string;
toTokenAddress: string;
amount: string;
}
) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, {
flow: CommerceFlowType.SWAP,
fromTokenAddress: options.fromTokenAddress || 'NATIVE',
toTokenAddress: options.toTokenAddress,
amount: options.amount,
});
return widget;
}
```
### Parameters
| Parameter | Type | Description |
| ------------------ | -------- | --------------------------------- |
| `flow` | `'SWAP'` | Required. Specifies the swap flow |
| `fromTokenAddress` | `string` | Optional. Token to swap from |
| `toTokenAddress` | `string` | Required. Token to swap to |
| `amount` | `string` | Optional. Amount to swap |
### Events
| Event | Description | Payload |
| --------- | ------------------ | --------------------------------------------------------------- |
| `SUCCESS` | Swap completed | `{ transactionHash, fromToken, toToken, fromAmount, toAmount }` |
| `FAILURE` | Swap failed | `{ error }` |
| `CLOSE` | User closed widget | — |
***
## Common tokens
| Token | Mainnet Address | Testnet Address |
| ----- | -------------------------------------------- | -------------------------------------------- |
| IMX | `NATIVE` | `NATIVE` |
| USDC | `0x6de8aCC0D406837030CE4dd28e7c08C5a96a30d2` | `0x3B2d8A1931736Fc321C24864BceEe981B11c3c57` |
| wETH | `0x52a6c53869ce09a731cd772f245b97a4401d3348` | `0xe9E96d1aad82562b7588F03f49aD34186f996478` |
***
## Next Steps
Buy tokens with fiat
Transfer from Ethereum
Sell NFTs to players
Complete marketplace tutorial
# Wallet
Source: https://docs.immutable.com/docs/products/checkout/wallet
Display token balances and provide quick access to swap, bridge, and onramp flows.
**When to use:**
* Showing player's token balances in-game
* Central hub for accessing funding options
* Post-login wallet dashboard
## Installation
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
## Quick Start
```typescript theme={null}
import { checkout } from '@imtbl/sdk';
import { Environment } from '@imtbl/sdk/config';
const { Checkout, WidgetType, WidgetTheme } = checkout;
const checkoutSDK = new Checkout({
baseConfig: {
environment: Environment.SANDBOX, // or Environment.PRODUCTION
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
const { CommerceFlowType, CommerceEventType } = checkout;
// Display wallet balances and actions
export async function showWallet(elementId: string) {
const widgets = await checkoutSDK.widgets({ config: { theme: WidgetTheme.DARK } });
const widget = widgets.create(WidgetType.IMMUTABLE_COMMERCE);
widget.mount(elementId, {
flow: CommerceFlowType.WALLET,
});
widget.addListener(CommerceEventType.CLOSE, () => {
widget.unmount();
});
widget.addListener(CommerceEventType.DISCONNECTED, () => {
widget.unmount();
// Handle logout in your app
});
return widget;
}
```
## Parameters
| Parameter | Type | Description |
| ---------------------- | ---------- | ---------------------------------------- |
| `flow` | `'WALLET'` | Required. Specifies the wallet flow |
| `showDisconnectButton` | `boolean` | Show disconnect option (default: `true`) |
| `showNetworkMenu` | `boolean` | Show network switcher (default: `true`) |
## Events
| Event | Description | Payload |
| -------------- | ------------------------- | ------------ |
| `CLOSE` | User closed widget | — |
| `DISCONNECTED` | User disconnected wallet | — |
| `USER_ACTION` | User initiated a sub-flow | `{ action }` |
### User Actions
The wallet can trigger other flows. Listen for these actions: `SWAP`, `BRIDGE`, `ONRAMP`.
## What Users See
The wallet flow displays:
1. **Connected wallet address** with copy button
2. **Token balances** on Immutable Chain
3. **Action buttons** for:
* Swap tokens
* Bridge from Ethereum
* Buy with fiat (onramp)
4. **Network selector** (if enabled)
5. **Disconnect button** (if enabled)
## Next Steps
Exchange tokens directly
Move tokens from Ethereum
# API Keys
Source: https://docs.immutable.com/docs/products/hub/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
Complete Hub setup guide
Use your secret key for minting
# Deploy Contracts
Source: https://docs.immutable.com/docs/products/hub/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/products/hub/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
# Getting Started
Source: https://docs.immutable.com/docs/products/hub/getting-started
Create your Immutable Hub account, project, and get your API credentials
## Create Your Account
Sign in at [hub.immutable.com](https://hub.immutable.com)
Create an **organisation** (container for projects and team members), then create a **project** (has its own API keys, Passport clients, and contracts)
## Get Your API Keys
Publishable and secret keys
## Environment Selection
| Environment | Use Case | Chain |
| -------------- | --------------------------- | ----------------------- |
| **Sandbox** | Development and testing | Immutable zkEVM Testnet |
| **Production** | Live games with real assets | Immutable zkEVM Mainnet |
Start with Sandbox for development. You can get free testnet tokens from the [Testnet Faucet](/docs/products/immutable-chain/faucet).
## Next Steps
Authentication clients
Smart contract deployment
API key management
Complete setup tutorial
# Hub
Source: https://docs.immutable.com/docs/products/hub/overview
Admin panel for managing your game on Immutable
Sign in or create an account to get started
## Why Use Hub?
Deploy [smart contracts](/docs/products/asset-contracts/overview) to [zkEVM](/docs/products/immutable-chain/overview), configure [Passport](/docs/products/passport/overview) authentication, and set up webhooks—all without writing code.
Manage everything in one place: contracts, API keys, analytics, and team access.
Pre-configured security settings, verified contracts, and production-ready defaults.
Invite team members with granular permissions. Audit logs track all changes.
## What You Can Do
Account, project, and API keys
Authentication clients and redirect URIs
Publishable and secret keys
ERC-721, ERC-1155, and ERC-20
Real-time blockchain event notifications
Team members and permissions
## Tutorials
Get started with Hub through hands-on tutorials:
Set up Hub project, configure Passport, and deploy contracts
Production readiness checklist for Hub configuration
# Passport Clients
Source: https://docs.immutable.com/docs/products/hub/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
# Team Management
Source: https://docs.immutable.com/docs/products/hub/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
# Webhooks
Source: https://docs.immutable.com/docs/products/hub/webhooks
Configure real-time notifications for blockchain events in Hub
## Why Use Webhooks?
| Approach | Latency | Complexity | Use Case |
| ------------ | ------------------ | ---------- | ------------------------------ |
| **Polling** | Seconds to minutes | Simple | Low-volume, non-critical |
| **Webhooks** | Sub-second | Moderate | Real-time updates, high-volume |
## Configure Webhooks
Configure in [Hub](https://hub.immutable.com): **Webhooks** → **Add Webhook**
**Endpoint URL:** e.g., `https://api.yourgame.com/webhooks/immutable`
**Events:** Mints, transfers, orders, trades
## Webhook Payload
```json theme={null}
{
"event_name": "imtbl_zkevm_mint_request_updated",
"event_id": "evt_123abc",
"timestamp": "2024-01-15T10:30:00Z",
"data": {
"contract_address": "0x...",
"token_id": "123",
"status": "succeeded"
}
}
```
## Verify Webhook Signatures
```typescript theme={null}
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// In your webhook handler
app.post('/webhooks/immutable', (req, res) => {
const signature = req.headers['x-immutable-signature'];
const isValid = verifyWebhookSignature(
JSON.stringify(req.body),
signature,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process the webhook
handleWebhookEvent(req.body);
res.status(200).send('OK');
});
```
## Retry Policy
Exponential backoff on errors:
| Attempt | Delay |
| ------- | ---------- |
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
After 5 failed attempts, view and retry in Hub.
## Best Practices
Return a 200 response immediately, then process the event asynchronously. Webhook requests timeout after 30 seconds.
Use the `event_id` to deduplicate events. The same event may be delivered more than once.
Always verify the webhook signature. Use HTTPS for your endpoint.
## Next Steps
Query blockchain data directly
Handle minting webhooks
# Bridging
Source: https://docs.immutable.com/docs/products/immutable-chain/bridging
Transfer assets between Ethereum and [Immutable Chain](/docs/products/immutable-chain/overview) using the canonical bridge or third-party solutions.
View the bridge contract source code
Trail of Bits audit and Perimeter fuzzing reports
## Bridge Options
| Bridge | Type | Speed | Best For |
| ----------------------------------------- | ----------------- | --------- | ------------------------------- |
| **[Canonical Bridge](#canonical-bridge)** | Native (Axelar) | 15-30 min | Large amounts, highest security |
| **[LayerSwap](https://layerswap.io)** | Liquidity Network | Minutes | Fast transfers |
| **[Squid](https://squidrouter.com)** | Aggregator | Minutes | Multi-chain routing |
The canonical bridge currently uses [Axelar](https://axelar.network/) for cross-chain messaging, with plans to add ZK proofs in the future for enhanced security.
## Bridge UIs
Best interface for [Passport](/docs/products/passport/overview) wallets
Canonical bridge for advanced users
## Canonical Bridge
The canonical bridge enables secure asset transfers between Ethereum (L1) and Immutable Chain (L2).
### Supported Assets
| Asset | L1 → L2 | L2 → L1 |
| --------------------- | ------- | ------- |
| **ETH** | ✅ | ✅ |
| **IMX** | ✅ | ✅ |
| **USDC** | ✅ | ✅ |
| **USDT** | ✅ | ✅ |
| **wBTC** | ✅ | ✅ |
| **any mapped ERC-20** | ✅ | ✅ |
### How It Works
**Deposit (L1 → L2)**: \~15-30 minutes
1. Call the bridge contract on Ethereum with the asset and destination
2. Wait for L1 confirmation and Axelar relay
3. Assets appear in your L2 wallet
**Withdrawal (L2 → L1)**: Up to 24 hours (see [Flow Rate Parameters](#flow-rate-parameters))
1. Call the bridge contract on Immutable Chain
2. Wait for message relay
3. Claim on Ethereum
### Flow Rate Parameters
The bridge implements safety mechanisms to protect against exploits:
| Token | Flow-Rate Capacity (4-Hour Window) | Large Withdrawal Threshold |
| ----------------------------------------------------------------------------- | ---------------------------------- | -------------------------- |
| ETH | 148 | 29.60 |
| [IMX](https://etherscan.io/token/0xF57e7e7C23978C3cAEC3C3548E3D615c346e79fF) | 539,052 | 107,230 |
| [USDC](https://etherscan.io/token/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48) | 372,000 | 74,000 |
| [USDT](https://etherscan.io/token/0xdAC17F958D2ee523a2206206994597C13D831ec7) | 372,000 | 74,000 |
| [wBTC](https://etherscan.io/token/0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599) | 3.37 | 0.67 |
| [GODS](https://etherscan.io/token/0xccC8cb5229B0ac8069C51fd58367Fd1e622aFD97) | 2,310,559 | 459,627 |
| [GOG](https://etherscan.io/token/0x9AB7bb7FdC60f4357ECFef43986818A2A3569c62) | 9,386,828 | 1,867,272 |
**Large withdrawals** exceeding the threshold are queued for 24 hours. If total withdrawals in a 4-hour window exceed the flow rate capacity, all withdrawals are queued until manually reviewed.
## Bridge Contract Addresses
### Ethereum (L1)
| Contract | Mainnet | Testnet (Sepolia) |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Bridge Proxy** | [`0xBa5E35E26Ae59c7aea6F029B68c6460De2d13eB6`](https://etherscan.io/address/0xBa5E35E26Ae59c7aea6F029B68c6460De2d13eB6) | [`0x0D3C59c779Fd552C27b23F723E80246c840100F5`](https://sepolia.etherscan.io/address/0x0D3C59c779Fd552C27b23F723E80246c840100F5) |
| **Adaptor Proxy** | [`0x4f49B53928A71E553bB1B0F66a5BcB54Fd4E8932`](https://etherscan.io/address/0x4f49B53928A71E553bB1B0F66a5BcB54Fd4E8932) | [`0x6328Ac88ba8D466a0F551FC7C42C61d1aC7f92ab`](https://sepolia.etherscan.io/address/0x6328Ac88ba8D466a0F551FC7C42C61d1aC7f92ab) |
### Immutable Chain (L2)
| Contract | Mainnet | Testnet |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Bridge Proxy** | [`0xBa5E35E26Ae59c7aea6F029B68c6460De2d13eB6`](https://explorer.immutable.com/address/0xBa5E35E26Ae59c7aea6F029B68c6460De2d13eB6) | [`0x0D3C59c779Fd552C27b23F723E80246c840100F5`](https://explorer.testnet.immutable.com/address/0x0D3C59c779Fd552C27b23F723E80246c840100F5) |
| **Adaptor Proxy** | [`0x4f49B53928A71E553bB1B0F66a5BcB54Fd4E8932`](https://explorer.immutable.com/address/0x4f49B53928A71E553bB1B0F66a5BcB54Fd4E8932) | [`0x6328Ac88ba8D466a0F551FC7C42C61d1aC7f92ab`](https://explorer.testnet.immutable.com/address/0x6328Ac88ba8D466a0F551FC7C42C61d1aC7f92ab) |
### Token Addresses (L2)
| Token | Mainnet | Testnet |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Wrapped ETH** | [`0x52a6c53869ce09a731cd772f245b97a4401d3348`](https://explorer.immutable.com/address/0x52a6c53869ce09a731cd772f245b97a4401d3348) | [`0xe9E96d1aad82562b7588F03f49aD34186f996478`](https://explorer.testnet.immutable.com/address/0xe9E96d1aad82562b7588F03f49aD34186f996478) |
| **Wrapped IMX** | [`0x3a0c2ba54d6cbd3121f01b96dfd20e99d1696c9d`](https://explorer.immutable.com/address/0x3a0c2ba54d6cbd3121f01b96dfd20e99d1696c9d) | [`0x1CcCa691501174B4A623CeDA58cC8f1a76dc3439`](https://explorer.testnet.immutable.com/address/0x1CcCa691501174B4A623CeDA58cC8f1a76dc3439) |
| **USDC** | [`0x6de8aCC0D406837030CE4dd28e7c08C5a96a30d2`](https://explorer.immutable.com/address/0x6de8aCC0D406837030CE4dd28e7c08C5a96a30d2) | [`0x3B2d8A1931736Fc321C24864BceEe981B11c3c57`](https://explorer.testnet.immutable.com/address/0x3B2d8A1931736Fc321C24864BceEe981B11c3c57) |
| **USDT** | [`0x68bcc7F1190AF20e7b572BCfb431c3Ac10A936Ab`](https://explorer.immutable.com/address/0x68bcc7F1190AF20e7b572BCfb431c3Ac10A936Ab) | TBA |
| **Wrapped BTC** | [`0x235F9A2Dc29E51cE7D103bcC5Dfb4F5c9c3371De`](https://explorer.immutable.com/address/0x235F9A2Dc29E51cE7D103bcC5Dfb4F5c9c3371De) | TBA |
| **GODS** | [`0xE0e0981D19eF2E0a57Cc48CA60D9454eD2D53fEB`](https://explorer.immutable.com/address/0xE0e0981D19eF2E0a57Cc48CA60D9454eD2D53fEB) | TBA |
| **GOG** | [`0xb00ed913aAFf8280C17BfF33CcE82fE6D79e85e8`](https://explorer.immutable.com/address/0xb00ed913aAFf8280C17BfF33CcE82fE6D79e85e8) | TBA |
## Querying Flow Rates
You can query flow rate parameters directly from the L1 bridge contract:
* `flowRateBuckets(tokenAddress)` - Returns bucket capacity and refill rate
* `largeTransferThreshold(tokenAddress)` - Returns the large withdrawal threshold
For programmatic bridging, see the manual bridging documentation
## Next Steps
Understand IMX token usage
Set up wallet integration
Review chain differences
# Differences from Ethereum
Source: https://docs.immutable.com/docs/products/immutable-chain/differences-from-ethereum
Immutable Chain is built from a geth fork and maintains high compatibility with Ethereum. However, there are some notable differences developers should be aware of.
View the Immutable geth fork on GitHub
## Solidity Compatibility
Immutable Chain's most recent hard fork aligns with Ethereum's Cancun fork. We officially support Solidity versions **up to and including 0.8.28**.
```solidity theme={null}
// Recommended pragma
pragma solidity >=0.8.19 <0.8.29;
```
If you use `^0.8.19`, you may pull a compiler version not compatible with Immutable Chain. Always pin your version.
## EVM Differences
### EIP-4844 Blobs
Blob transactions are **not supported**. Any such transactions will be rejected by the RPC.
| Op Code | Behavior |
| ------------- | ----------------------------- |
| `BLOBHASH` | Returns empty `bytes32` (0x0) |
| `BLOBBASEFEE` | Returns 0x1 |
Block headers `blobGasUsed` and `excessBlobGas` are always 0x0.
### PREVRANDAO Op Code
The `PREVRANDAO` op code (EIP-4399) always returns `uint256(0)` on Immutable Chain.
Do not rely on `PREVRANDAO` for randomness. Use an oracle like [Supra VRF](https://supra.com/vrf-product/) instead.
## RPC Endpoint Differences
Some RPC methods are disabled for security and performance:
| Category | Methods | Reason |
| ---------- | ---------- | --------------------------------- |
| **Debug** | `debug_*` | Unbounded memory usage, DDoS risk |
| **Engine** | `engine_*` | Not needed for external use |
| **Txpool** | `txpool_*` | Prevents front-running |
| **Admin** | `admin_*` | Internal operations only |
For debug methods, use [QuickNode](https://www.quicknode.com/chains/imx) which provides dedicated endpoints. See [Ecosystem Partners](/docs/products/immutable-chain/ecosystem-partners).
## Gas Pricing
Immutable Chain uses EIP-1559 gas pricing. See the full gas configuration:
| Property | Value |
| -------------------- | ---------------------------- |
| **Gas Model** | EIP-1559 |
| **Min Priority Fee** | 10 gwei |
| **Block Gas Limit** | 30,000,000 |
| **Gas Sponsorship** | Available for Passport users |
```typescript theme={null}
// Always set minimum priority fee
const tx = await contract.method({
maxPriorityFeePerGas: 10_000_000_000n, // 10 gwei
maxFeePerGas: 15_000_000_000n,
});
```
See [Gas Sponsorship](/docs/products/passport/gas-sponsorship) for sponsored transactions.
## Resources
Network details and RPC endpoints
Core protocol addresses
## Next Steps
Deploy contracts with Hardhat
Network configuration details
Sponsor user transactions
Find RPC and development tools
# Ecosystem Partners
Source: https://docs.immutable.com/docs/products/immutable-chain/ecosystem-partners
Third-party providers and tools for building on [Immutable Chain](/docs/products/immutable-chain/overview).
## RPC Providers
| Provider | Description | Link |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- |
| **QuickNode** | Leading RPC provider with scalable infrastructure. Contact [nanthony@quicknode.com](mailto:nanthony@quicknode.com) for enterprise pricing. | [quicknode.com](https://www.quicknode.com/chains/imx) |
| **dRPC** | Decentralized RPC with 50+ blockchains, transparent pay-as-you-go pricing | [drpc.org](https://drpc.org) |
## Indexers
| Provider | Description | Link |
| ------------ | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- |
| **Goldsky** | GraphQL provider for efficient data retrieval and complex queries. See [Indexer](/docs/products/indexer/overview). | [goldsky.com](https://goldsky.com) |
| **SubQuery** | Query blockchain data without complex infrastructure | [subquery.network](https://subquery.network) |
## DAO Governance
| Provider | Description | Link |
| --------- | ---------------------------------------------------------------------------- | ------------------------------------------ |
| **Tally** | Comprehensive governance platform for transparent voting and decision-making | [withtally.com](https://www.withtally.com) |
## Multi-Sig Wallets
| Provider | Description | Link |
| -------- | ---------------------------------------------------------------------------- | ------------------------------------------------- |
| **Safe** | Secure multi-signature smart contract wallets (fork maintained by Protofire) | [safe.immutable.com](https://safe.immutable.com/) |
## Decentralized Storage
| Provider | Description | Link |
| ----------- | ----------------------------------------------------- | -------------------------------------- |
| **Arweave** | Permanent, tamper-proof data storage for one-time fee | [arweave.org](https://www.arweave.org) |
## Fiat On-Ramps
| Provider | Link |
| ----------- | ---------------------------------- |
| **Transak** | [transak.com](https://transak.com) |
See [Checkout](/docs/products/checkout/onramp) for integrated onramp solutions.
## Next Steps
Learn about Immutable Chain
Run your own node infrastructure
Query blockchain data efficiently
Integrate fiat onramp solutions
# Faucet
Source: https://docs.immutable.com/docs/products/immutable-chain/faucet
Request test-IMX tokens for [Immutable Chain](/docs/products/immutable-chain/overview) testnet development.
Request test tokens from [Hub](/docs/products/hub/overview)
## Limits
| Limit | Value |
| ------------ | ---------------------------------------------------------- |
| **Amount** | 10 [test-IMX](/docs/products/immutable-chain/native-token) |
| **Cooldown** | 24 hours |
## Next Steps
Learn about Immutable Chain
Understand IMX token usage
# Immutable X Deprecation
Source: https://docs.immutable.com/docs/products/immutable-chain/immutable-x-deprecation
Immutable X was Immutable's first rollup, which was merged into [Immutable Chain](/docs/products/immutable-chain/overview) in early 2026.
Built in partnership with [StarkWare](https://www.starkware.co/) as a customised deployment of [StarkEx](https://www.starkware.co/starkex), Immutable X processed hundreds of millions of transactions and billions of dollars in game asset trading volume.
# Merge with Immutable Chain
The Immutable X [bridge](/docs/products/immutable-chain/bridging) was merged with the primary bridge for Immutable Chain through a non-custodial bridge contract upgrade.
If you had ETH, IMX or any other ERC-20 on Immutable X, your assets were migrated directly to Immutable Chain, and are available in the same wallet you used on Immutable X (e.g. MetaMask, [Passport](/docs/products/passport/overview)). Immutable had no access to your private keys or funds during this process.
If you had NFTs on Immutable X, their status depends on the specific game and collection. Most assets were migrated automatically,
but many games and projects chose to implement custom logic for migrating their assets. In these cases, please consult with the project directly.
Bridge upgrade smart contracts
Nethermind audit report
# Deprecation Process and Status
All Immutable X functionality is now deprecated and will be removed in future.
| Interface | Status |
| -------------------------------------------------- | ------------------------------------------ |
| Sequencer RPC (Writes) | No longer processing new transactions |
| Sequencer RPC (Reads) | Deprecated, will be removed in future |
| [Explorer](https://immutascan.io) | Deprecated, will be removed in future |
| [Marketplace](https://marketplace.x.immutable.com) | Removed |
| `@imtbl/sdk` (Immutable X code and packages) | Deprecated |
| `@imtbl/core-sdk` | Deprecated, please migrate to `@imtbl/sdk` |
| `@imtbl/imx-sdk-js` | Deprecated, please migrate to `@imtbl/sdk` |
## Next Steps
Learn about Immutable Chain
Understand bridge migration
Set up Passport wallet integration
Deploy NFT contracts on Immutable Chain
# Native Token
Source: https://docs.immutable.com/docs/products/immutable-chain/native-token
The native token of [Immutable Chain](/docs/products/immutable-chain/overview) is **IMX**, issued by the IMX Ecosystem Foundation.
On Immutable Chain it is used for gas and can be staked. On Ethereum it exists as an [ERC20](/docs/products/asset-contracts/erc20).
## Token Details
| Network | Symbol | Address | Wrapped (wIMX) |
| ----------------------- | ------ | -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| **Immutable (mainnet)** | IMX | Native | [0x3a0c...9c9d](https://explorer.immutable.com/address/0x3a0c2ba54d6cbd3121f01b96dfd20e99d1696c9d) |
| **Immutable (testnet)** | tIMX | Native | [0x1CcC...3439](https://explorer.testnet.immutable.com/address/0x1CcCa691501174B4A623CeDA58cC8f1a76dc3439) |
| **Ethereum** | IMX | [0xf57e...79ff](https://etherscan.io/token/0xf57e7e7c23978c3caec3c3548e3d615c346e79ff) | — |
**Deposit (Ethereum → Immutable):** The canonical bridge credits **native IMX** on Immutable Chain when you bridge ERC20 IMX from Ethereum.
**Wrapped IMX (wIMX)** is an ERC20 on Immutable Chain created by wrapping native IMX (e.g. for DeFi). You can wrap and unwrap via [Immutable Toolkit](https://toolkit.immutable.com/wrap-imx/).
Withdrawals to Ethereum accept either native IMX or wIMX and deliver ERC20 IMX on L1.
## Staking
Staking on Immutable Chain is provided by the IMX Ecosystem Foundation.
For more information and to participate, see [imx.community](https://imx.community/staking).
## Getting IMX
| Environment | Option |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **Testnet** | [Faucet](/docs/products/immutable-chain/faucet) (test-IMX from Hub) |
| **Mainnet** | [Canonical Bridge](/docs/products/immutable-chain/bridging) from Ethereum, or acquire on supported exchanges/third-party bridges |
## Next Steps
Stake IMX via IMX Ecosystem Foundation
Move IMX between Ethereum and Immutable Chain
Get testnet tIMX for development
Sponsor gas for players
# Immutable Chain
Source: https://docs.immutable.com/docs/products/immutable-chain/overview
The gaming-optimised blockchain built on Ethereum
Fast confirmations for real-time gaming and trading
[Gas sponsorship](/docs/products/passport/gas-sponsorship) means players never pay fees
Live, audited, and monitored [bridge contracts](/docs/products/immutable-chain/bridging)
Use Solidity, [Foundry, Hardhat](/docs/products/asset-contracts/deploy-contracts-with-hardhat), [viem](/docs/sdks/typescript/overview) and more
View the Immutable geth fork on GitHub
## Network Configuration
### Basic Configuration
| Property | Testnet | Mainnet |
| --------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------- |
| **Network Name** | Immutable zkEVM Testnet | Immutable zkEVM |
| **Chain ID** | 13473 | 13371 |
| **RPC URL** | `https://rpc.testnet.immutable.com` | `https://rpc.immutable.com` |
| **Currency Symbol** | tIMX | IMX |
| **Currency Decimals** | 18 | 18 |
| **Block Explorer** | [explorer.testnet.immutable.com](https://explorer.testnet.immutable.com) | [explorer.immutable.com](https://explorer.immutable.com) |
### Chain Details
| Property | Testnet | Mainnet |
| -------------------- | ------------ | ------------ |
| **Parent Chain** | Sepolia | Ethereum |
| **Block Time** | \~2 seconds | \~2 seconds |
| **Finality** | \~2 seconds | \~2 seconds |
| **EVM Version** | Cancun | Cancun |
| **Solidity Support** | Up to 0.8.28 | Up to 0.8.28 |
### Gas Configuration
| Property | Value |
| -------------------- | ---------------------------- |
| **Gas Model** | EIP-1559 |
| **Min Priority Fee** | 10 gwei |
| **Block Gas Limit** | 30,000,000 |
| **Gas Sponsorship** | Available for Passport users |
## Connect with Code
```typescript theme={null}
import { createPublicClient, createWalletClient, http } from 'viem';
import { immutableZkEvm, immutableZkEvmTestnet } from 'viem/chains';
// Mainnet - using built-in chain definition
const publicClient = createPublicClient({
chain: immutableZkEvm,
transport: http(),
});
// Testnet
const testnetClient = createPublicClient({
chain: immutableZkEvmTestnet,
transport: http(),
});
// With custom RPC (e.g., QuickNode)
const customClient = createPublicClient({
chain: immutableZkEvm,
transport: http('https://your-quicknode-endpoint.com'),
});
```
```typescript theme={null}
import { ethers } from 'ethers';
// Mainnet
const mainnet = new ethers.JsonRpcProvider('https://rpc.immutable.com', {
chainId: 13371,
name: 'immutable-zkevm',
});
// Testnet
const testnet = new ethers.JsonRpcProvider('https://rpc.testnet.immutable.com', {
chainId: 13473,
name: 'immutable-zkevm-testnet',
});
```
```toml theme={null}
# foundry.toml
[rpc_endpoints]
imtbl_testnet = "https://rpc.testnet.immutable.com"
imtbl_mainnet = "https://rpc.immutable.com"
# Deploy command
# forge script script/Deploy.s.sol --rpc-url imtbl_testnet --broadcast
```
```typescript theme={null}
// hardhat.config.ts
import { HardhatUserConfig } from 'hardhat/config';
const config: HardhatUserConfig = {
networks: {
'imtbl-zkevm-testnet': {
url: 'https://rpc.testnet.immutable.com',
accounts: [process.env.PRIVATE_KEY!],
chainId: 13473,
},
'imtbl-zkevm-mainnet': {
url: 'https://rpc.immutable.com',
accounts: [process.env.PRIVATE_KEY!],
chainId: 13371,
},
},
};
export default config;
```
## RPC Rate Limits
| Tier | Requests/sec | Use Case |
| ------------- | ------------ | ------------------------ |
| **Public** | 25 | Development, light usage |
| **QuickNode** | Custom | Production |
| **dRPC** | Custom | Production |
For production applications, use [QuickNode](https://www.quicknode.com/chains/imx) or [dRPC](https://drpc.org/chainlist/immutable-zkevm-mainnet-rpc) for dedicated endpoints with higher limits.
## Resources
Bridge, CEX and other chain utilities
Run your own Immutable Chain node
EVM compatibility details
Transfer assets from Ethereum
Infrastructure and partners
Get testnet tokens
Open source repositories
## Next Steps
Deploy your first contract
Integrate wallet authentication
Sponsor user transactions
Bridge assets from Ethereum
# Running Nodes
Source: https://docs.immutable.com/docs/products/immutable-chain/running-nodes
Run your own node to connect to [Immutable Chain](/docs/products/immutable-chain/overview).
View the node software source code
Immutable Chain supports two types of nodes for connecting to the network:
| Type | Description | Access |
| ----------- | ----------------------------------------------------------------------- | --------------------- |
| **Public** | For partners wanting to run a node in the network | Permissionless |
| **Private** | For critical infrastructure partners with direct Immutable relationship | Requires allowlisting |
## Public Nodes
Public nodes do not participate in txpool gossiping—they forward all transactions directly to the Immutable RPC endpoint. This manages gossiping load while processing all transactions normally.
### Requirements
* **Hardware**: 2 AWS vCPU, 4GB RAM, 100GB free storage
* **OS**: Ubuntu 22.04.1 (tested)
* **Software**: [Docker](https://docs.docker.com/engine/install/ubuntu/)
### Setup Instructions
**1. Create data directory**
```bash theme={null}
mkdir /opt/immutable-zkevm
```
**2. Pull the Docker image**
```bash theme={null}
docker pull ghcr.io/immutable/immutable-geth/immutable-geth:latest
docker tag ghcr.io/immutable/immutable-geth/immutable-geth:latest geth
```
**3. Initialize the node**
```bash theme={null}
docker run \
--rm \
-v /opt/immutable-zkevm:/mnt/geth \
--name geth \
geth immutable bootstrap rpc \
--zkevm testnet \
--datadir /mnt/geth
```
```bash theme={null}
docker run \
--rm \
-v /opt/immutable-zkevm:/mnt/geth \
--name geth \
geth immutable bootstrap rpc \
--zkevm mainnet \
--datadir /mnt/geth
```
**4. Start the node as a service**
```bash theme={null}
docker run \
-d \
--restart=always \
-v /opt/immutable-zkevm:/mnt/geth \
--name geth \
-p 8545:8545 \
geth \
--zkevm testnet \
--config /etc/geth/testnet-public.toml \
--datadir /mnt/geth \
--http \
--http.port "8545" \
--http.addr "0.0.0.0" \
--gossipdefault \
--disabletxpoolgossip \
--rpcproxy
```
```bash theme={null}
docker run \
-d \
--restart=always \
-v /opt/immutable-zkevm:/mnt/geth \
--name geth \
-p 8545:8545 \
geth \
--zkevm mainnet \
--config /etc/geth/mainnet-public.toml \
--datadir /mnt/geth \
--http \
--http.port "8545" \
--http.addr "0.0.0.0" \
--gossipdefault \
--disabletxpoolgossip \
--rpcproxy
```
### Verify Deployment
**Check logs**
```bash theme={null}
docker logs geth
```
You should see output indicating the node is syncing:
```
INFO [12-04|04:54:26.754] Chain ID: 13473 (unknown)
INFO [12-04|04:54:26.754] Consensus: Clique (proof-of-authority)
INFO [12-04|04:54:36.978] Block synchronisation started
INFO [12-04|04:54:37.205] Imported new chain segment number=1,343,079
```
**Verify chain ID**
```bash theme={null}
curl http://localhost:8545 \
-X POST \
-H "Content-Type: application/json" \
--data '{"method":"eth_chainId","params":[],"id":1,"jsonrpc":"2.0"}'
```
Expected responses:
* Testnet: `{"jsonrpc":"2.0","id":1,"result":"0x34a1"}` (13473)
* Mainnet: `{"jsonrpc":"2.0","id":1,"result":"0x343b"}` (13371)
## Private Nodes
Private nodes are for partners providing critical infrastructure with a direct relationship with Immutable. They require allowlisting via WireGuard or static IP.
Most node operators should use the permissionless [public node](#public-nodes) setup. Contact your Immutable representative if you need private node access.
### Access Methods
**WireGuard**: Generate keys and provide your public key to Immutable:
```bash theme={null}
sudo apt-get install wireguard
umask 077
wg genkey > wg-privatekey
wg pubkey < wg-privatekey > wg-publickey
```
After receiving your WireGuard config, update `PrivateKey` and start:
```bash theme={null}
# Save config to /etc/wireguard/wg0.conf
wg-quick up wg0
```
**Static IP**: Provide your static IP to your Immutable contact for allowlisting.
### Private Node Startup
Running private nodes is the same as the process for public nodes described above, with the exception of
step 4, "Start the node as a service":
```bash theme={null}
docker run \
-d \
--restart=always \
-v /opt/immutable-zkevm:/mnt/geth \
--name geth \
-p 8545:8545 \
geth \
--zkevm testnet \
--config /etc/geth/testnet.toml \
--datadir /mnt/geth \
--http \
--http.port "8545" \
--http.addr "0.0.0.0"
```
```bash theme={null}
docker run \
-d \
--restart=always \
-v /opt/immutable-zkevm:/mnt/geth \
--name geth \
-p 8545:8545 \
geth \
--zkevm mainnet \
--config /etc/geth/mainnet.toml \
--datadir /mnt/geth \
--http \
--http.port "8545" \
--http.addr "0.0.0.0"
```
## Next Steps
Learn about Immutable Chain
Explore RPC provider partners
Review chain differences
Get testnet tokens
# Metadata Search
Source: https://docs.immutable.com/docs/products/indexer/metadata-search
Filter NFTs by their attributes—essential for building inventory filters and marketplace search.
## Use Cases
| Scenario | Query |
| ---------------------- | ---------------------------------- |
| **Inventory filter** | Show only "Legendary" rarity items |
| **Marketplace search** | Find swords with attack > 50 |
| **Crafting UI** | Display items of type "Material" |
| **Leaderboards** | Rank by numeric attribute |
## How It Works
NFT metadata follows a standard structure:
```json theme={null}
{
"name": "Dragon Slayer",
"description": "A legendary sword",
"image": "https://...",
"attributes": [
{ "trait_type": "Rarity", "value": "Legendary" },
{ "trait_type": "Type", "value": "Sword" },
{ "trait_type": "Attack", "value": 85, "display_type": "number" },
{ "trait_type": "Element", "value": "Fire" }
]
}
```
The Indexer indexes these attributes, allowing you to query by them.
## Query Syntax
### Filter by String Attribute
```typescript theme={null}
import { BlockchainData } from '@imtbl/blockchain-data';
import { Environment } from '@imtbl/config';
const indexer = new BlockchainData({
baseConfig: {
environment: Environment.SANDBOX,
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
// Find all Legendary items
const { result } = await indexer.listNFTs({
chainName: 'imtbl-zkevm-testnet',
contractAddress: COLLECTION_ADDRESS,
metadataFilters: [
{ key: 'Rarity', value: 'Legendary' }
],
});
```
### Filter by Multiple Attributes
```typescript theme={null}
// Find Legendary Swords
const { result } = await indexer.listNFTs({
chainName: 'imtbl-zkevm-testnet',
contractAddress: COLLECTION_ADDRESS,
metadataFilters: [
{ key: 'Rarity', value: 'Legendary' },
{ key: 'Type', value: 'Sword' },
],
});
```
Multiple filters are combined with AND logic. An NFT must match all filters to be returned.
### Filter by Numeric Range
```typescript theme={null}
// Find items with Attack between 50 and 100
const { result } = await indexer.listNFTs({
chainName: 'imtbl-zkevm-testnet',
contractAddress: COLLECTION_ADDRESS,
metadataFilters: [
{ key: 'Attack', value: { gte: 50, lte: 100 } }
],
});
```
| Operator | Meaning |
| -------- | --------------------- |
| `gte` | Greater than or equal |
| `gt` | Greater than |
| `lte` | Less than or equal |
| `lt` | Less than |
### Combine with Owner Filter
```typescript theme={null}
// Find the player's Legendary items
const { result } = await indexer.listNFTsByAccountAddress({
chainName: 'imtbl-zkevm-testnet',
accountAddress: playerAddress,
contractAddress: COLLECTION_ADDRESS,
metadataFilters: [
{ key: 'Rarity', value: 'Legendary' }
],
});
```
## Building Inventory Filters
### Define Your Filters
```typescript theme={null}
interface InventoryFilters {
rarity?: string[];
type?: string[];
minLevel?: number;
maxLevel?: number;
}
async function getFilteredInventory(
address: string,
filters: InventoryFilters
) {
const metadataFilters: MetadataFilter[] = [];
// String filters - user can select multiple values
if (filters.rarity?.length) {
// Note: Multiple values for same trait = OR logic
// Query separately and merge results
const results = await Promise.all(
filters.rarity.map(rarity =>
indexer.listNFTsByAccountAddress({
chainName: 'imtbl-zkevm-testnet',
accountAddress: address,
contractAddress: COLLECTION_ADDRESS,
metadataFilters: [{ key: 'Rarity', value: rarity }],
})
)
);
// Deduplicate by token_id
const seen = new Set();
return results.flatMap(r => r.result).filter(nft => {
if (seen.has(nft.token_id)) return false;
seen.add(nft.token_id);
return true;
});
}
// Numeric range filter
if (filters.minLevel !== undefined || filters.maxLevel !== undefined) {
const range: any = {};
if (filters.minLevel !== undefined) range.gte = filters.minLevel;
if (filters.maxLevel !== undefined) range.lte = filters.maxLevel;
metadataFilters.push({ key: 'Level', value: range });
}
const { result } = await indexer.listNFTsByAccountAddress({
chainName: 'imtbl-zkevm-testnet',
accountAddress: address,
contractAddress: COLLECTION_ADDRESS,
metadataFilters,
});
return result;
}
```
### React Filter Component
```typescript theme={null}
function InventoryFilters({ onFilterChange }: { onFilterChange: (filters: InventoryFilters) => void }) {
const [rarity, setRarity] = useState([]);
const [type, setType] = useState([]);
const [levelRange, setLevelRange] = useState({ min: 0, max: 100 });
useEffect(() => {
onFilterChange({
rarity: rarity.length ? rarity : undefined,
type: type.length ? type : undefined,
minLevel: levelRange.min || undefined,
maxLevel: levelRange.max < 100 ? levelRange.max : undefined,
});
}, [rarity, type, levelRange]);
return (
);
}
```
## Marketplace Search
### Building a Search API
```typescript theme={null}
// API endpoint for marketplace search
app.get('/api/marketplace/search', async (req, res) => {
const {
collection,
rarity,
type,
minPrice,
maxPrice,
sortBy = 'price_asc',
page = 1,
} = req.query;
// Build metadata filters
const metadataFilters: MetadataFilter[] = [];
if (rarity) metadataFilters.push({ key: 'Rarity', value: rarity });
if (type) metadataFilters.push({ key: 'Type', value: type });
// Get NFTs matching metadata criteria
const { result: nfts } = await indexer.listNFTs({
chainName: 'imtbl-zkevm-mainnet',
contractAddress: collection,
metadataFilters,
pageSize: 100,
});
// Get active listings for these NFTs
const tokenIds = nfts.map(n => n.token_id);
const { result: listings } = await orderbook.listListings({
sellItemContractAddress: collection,
sellItemTokenId: tokenIds, // Filter to specific tokens
status: 'ACTIVE',
});
// Match NFTs with their listings
const listingsMap = new Map(listings.map(l => [l.sell[0].tokenId, l]));
const results = nfts
.map(nft => ({
...nft,
listing: listingsMap.get(nft.token_id),
}))
.filter(item => item.listing) // Only show listed items
.filter(item => {
const price = BigInt(item.listing.buy[0].amount);
if (minPrice && price < BigInt(minPrice)) return false;
if (maxPrice && price > BigInt(maxPrice)) return false;
return true;
});
// Sort
results.sort((a, b) => {
const priceA = BigInt(a.listing.buy[0].amount);
const priceB = BigInt(b.listing.buy[0].amount);
return sortBy === 'price_asc'
? Number(priceA - priceB)
: Number(priceB - priceA);
});
res.json({
items: results.slice((page - 1) * 20, page * 20),
total: results.length,
});
});
```
## Getting Available Filters
Dynamically discover which attributes exist in a collection:
```typescript theme={null}
async function getCollectionAttributes(contractAddress: string) {
const { result: nfts } = await indexer.listNFTs({
chainName: 'imtbl-zkevm-testnet',
contractAddress,
pageSize: 200,
});
// Extract unique attribute keys and values
const attributes = new Map>();
for (const nft of nfts) {
for (const attr of nft.attributes || []) {
if (!attributes.has(attr.trait_type)) {
attributes.set(attr.trait_type, new Set());
}
attributes.get(attr.trait_type)!.add(String(attr.value));
}
}
// Convert to filter options
return Array.from(attributes.entries()).map(([trait, values]) => ({
trait,
values: Array.from(values).sort(),
}));
}
// Result:
// [
// { trait: 'Rarity', values: ['Common', 'Epic', 'Legendary', 'Rare', 'Uncommon'] },
// { trait: 'Type', values: ['Armor', 'Material', 'Potion', 'Shield', 'Sword'] },
// { trait: 'Level', values: ['1', '10', '15', '20', ...] },
// ]
```
Cache collection attributes—they change rarely. Refresh when new NFTs are minted.
## Metadata Best Practices
### Consistent Trait Names
```json theme={null}
// ✅ Good - consistent naming
{ "trait_type": "Rarity", "value": "Legendary" }
{ "trait_type": "Rarity", "value": "Epic" }
// ❌ Bad - inconsistent
{ "trait_type": "Rarity", "value": "legendary" } // lowercase
{ "trait_type": "rarity", "value": "Epic" } // different case
```
### Numeric Attributes
```json theme={null}
// ✅ Good - use display_type for numbers
{
"trait_type": "Attack",
"value": 85,
"display_type": "number"
}
// ❌ Bad - number as string
{
"trait_type": "Attack",
"value": "85"
}
```
### Searchable Categories
Design attributes with filtering in mind:
| Attribute | Type | Example Values |
| --------- | ------------- | --------------------------------------- |
| Rarity | String (enum) | Common, Uncommon, Rare, Epic, Legendary |
| Type | String (enum) | Sword, Shield, Armor, Potion |
| Level | Number | 1-100 |
| Attack | Number | 0-999 |
| Element | String (enum) | Fire, Water, Earth, Air |
## Next Steps
Integration patterns and caching
Set up your collection metadata
Add marketplace search to trading
Implement marketplace features
# Indexer
Source: https://docs.immutable.com/docs/products/indexer/overview
Query Immutable Chain data—NFTs, tokens, activities, and orders—via APIs and webhooks, without running your own infrastructure.
## Why Use Indexer?
Query blockchain data via simple API calls. No need to run nodes, databases, or indexing pipelines.
Data indexed within seconds of on-chain confirmation. Webhooks push updates instantly.
Filter by owner, collection, attributes, activity type, and more. Get exactly the data you need.
Handles millions of queries daily across the Immutable ecosystem.
## Data Available
| Category | Examples |
| --------------- | --------------------------------------- |
| **NFTs** | Ownership, metadata, attributes, images |
| **Tokens** | ERC-20 balances, transfers |
| **Activities** | Mints, transfers, burns, sales |
| **Collections** | Stats, metadata, floor prices |
| **Orders** | Active listings and bids |
## Integration Patterns
Choose the right pattern for your use case:
### Polling vs Webhooks
| Pattern | Best For | Trade-offs |
| ------------ | ---------------------------------- | ----------------------------------------- |
| **Polling** | Player-driven requests, low volume | Simple, but may hit rate limits |
| **Webhooks** | Server sync, real-time updates | Efficient, requires server infrastructure |
Use polling when:
* Player opens inventory (fetch their NFTs)
* Player views an item (fetch metadata)
* Player searches marketplace (query listings)
```typescript theme={null}
import { BlockchainData } from '@imtbl/blockchain-data';
import { Environment } from '@imtbl/config';
const indexer = new BlockchainData({
baseConfig: {
environment: Environment.SANDBOX,
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
```typescript theme={null}
// Player opens inventory → fetch their NFTs
async function loadInventory(address: string) {
const { result } = await indexer.listNFTsByAccountAddress({
chainName: 'imtbl-zkevm-testnet',
accountAddress: address,
});
return result;
}
```
Use webhooks when:
* Syncing to your database
* Triggering game events on sales/transfers
* Real-time leaderboards or activity feeds
Configure webhooks in [Hub](https://hub.immutable.com).
| Event | Trigger |
| ------------------------------- | -------------------- |
| `imtbl_zkevm_activity_mint` | NFT minted |
| `imtbl_zkevm_activity_transfer` | NFT transferred |
| `imtbl_zkevm_activity_burn` | NFT burned |
| `imtbl_zkevm_activity_sale` | NFT sold |
| `imtbl_zkevm_order_updated` | Order status changed |
### Caching Strategy
Reduce API calls with smart caching:
```typescript theme={null}
const CACHE_TTL = {
nftMetadata: 24 * 60 * 60 * 1000, // 24 hours - rarely changes
nftOwnership: 30 * 1000, // 30 seconds - changes on transfers
collectionStats: 5 * 60 * 1000, // 5 minutes - aggregated data
activities: 0, // No cache - always fresh
};
class IndexerCache {
private cache = new Map();
async get(key: string, ttl: number, fetcher: () => Promise): Promise {
const cached = this.cache.get(key);
if (cached && cached.expires > Date.now()) {
return cached.data as T;
}
const data = await fetcher();
this.cache.set(key, { data, expires: Date.now() + ttl });
return data;
}
}
// Usage
const cache = new IndexerCache();
async function getNFTMetadata(contractAddress: string, tokenId: string) {
return cache.get(
`nft:${contractAddress}:${tokenId}`,
CACHE_TTL.nftMetadata,
() => indexer.getNFT({ chainName: 'imtbl-zkevm-testnet', contractAddress, tokenId })
);
}
```
### Pagination
For large result sets, paginate efficiently:
```typescript theme={null}
async function getAllNFTs(contractAddress: string): Promise {
const allNFTs: NFT[] = [];
let cursor: string | undefined;
do {
const { result, page } = await indexer.listNFTs({
chainName: 'imtbl-zkevm-testnet',
contractAddress,
pageSize: 200, // Max page size
pageCursor: cursor,
});
allNFTs.push(...result);
cursor = page.nextCursor;
} while (cursor);
return allNFTs;
}
```
Full collection syncs can be slow. Use webhooks for keeping a database in sync rather than periodic full fetches.
### Rate Limit Handling
```typescript theme={null}
async function fetchWithRetry(
fn: () => Promise,
maxRetries = 3
): Promise {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error: any) {
if (error.status === 429 && i < maxRetries - 1) {
// Rate limited - exponential backoff
await new Promise(r => setTimeout(r, Math.pow(2, i) * 1000));
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}
```
| Tier | Rate Limit |
| ---------- | ---------- |
| Standard | 50 req/sec |
| Enterprise | Custom |
## Common Patterns
### Player Inventory
```typescript theme={null}
async function getPlayerInventory(address: string, contractAddress?: string) {
const { result } = await indexer.listNFTsByAccountAddress({
chainName: 'imtbl-zkevm-testnet',
accountAddress: address,
contractAddress, // Optional: filter to specific collection
});
return result.map(nft => ({
id: nft.token_id,
name: nft.name,
image: nft.image,
attributes: nft.attributes,
}));
}
```
### Activity Feed
```typescript theme={null}
async function getRecentActivity(contractAddress: string) {
const { result } = await indexer.listActivities({
chainName: 'imtbl-zkevm-testnet',
contractAddress,
activityType: 'sale', // or 'mint', 'transfer', 'burn'
pageSize: 20,
});
return result.map(activity => ({
type: activity.activity_type,
from: activity.from,
to: activity.to,
tokenId: activity.token_id,
timestamp: activity.indexed_at,
price: activity.details?.amount,
}));
}
```
### Collection Stats
```typescript theme={null}
async function getCollectionOverview(contractAddress: string) {
const collection = await indexer.getCollection({
chainName: 'imtbl-zkevm-testnet',
contractAddress,
});
return {
name: collection.name,
totalSupply: collection.total_supply,
holders: collection.distinct_owners,
// Note: floor price comes from Orderbook, not Indexer
};
}
```
## Webhook Integration
### Verifying Webhooks
Always verify webhook signatures:
```typescript theme={null}
import crypto from 'crypto';
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// In your webhook handler
app.post('/webhooks/immutable', (req, res) => {
const signature = req.headers['x-immutable-signature'];
const isValid = verifyWebhookSignature(
JSON.stringify(req.body),
signature,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
// Process the webhook
handleWebhookEvent(req.body);
res.status(200).send('OK');
});
```
### Database Sync Pattern
```typescript theme={null}
// When receiving a transfer webhook
async function handleTransfer(data: TransferEvent) {
const { contract_address, token_id, from, to } = data;
await db.nft.update({
where: { contractAddress: contract_address, tokenId: token_id },
data: { owner: to },
});
// Optionally fetch fresh data to ensure consistency
const nft = await indexer.getNFT({
chainName: 'imtbl-zkevm-testnet',
contractAddress: contract_address,
tokenId: token_id,
});
await db.nft.update({
where: { contractAddress: contract_address, tokenId: token_id },
data: {
owner: nft.owner,
metadata: nft.metadata,
},
});
}
```
Webhooks are available to partners with a managed relationship. [Contact us](https://www.immutable.com/contact) to enable webhooks.
## Base URLs
| Environment | URL |
| ----------- | ----------------------------------- |
| Testnet | `https://api.sandbox.immutable.com` |
| Mainnet | `https://api.immutable.com` |
## Next Steps
Filter NFTs by attributes
Use the Indexer in your marketplace
Build trading functionality
Understand NFT contract structure
# Bulk Operations
Source: https://docs.immutable.com/docs/products/orderbook/bulk-operations
Create multiple listings and fulfill multiple orders in single transactions for efficient marketplace operations
Learn how to batch multiple orderbook operations into single transactions, improving user experience and reducing gas costs. Essential for marketplace shopping carts and bulk listing management.
## Overview
Bulk operations enable users to:
* **Bulk Listings:** Create up to **20 listings** with one signature
* **Bulk Fulfillment:** Buy up to **50 orders** in one transaction (shopping cart)
**Benefits:**
* Fewer wallet confirmations (better UX)
* Lower gas costs (batched transactions)
* Faster marketplace operations
* Enable shopping cart functionality
See [Getting Started](/docs/products/orderbook/overview#getting-started) for prerequisites and installation. For bulk operations, you should also understand [creating individual listings](/docs/products/orderbook/create-listings) and [filling individual orders](/docs/products/orderbook/fill-orders).
***
# Bulk Listing Creation
Create multiple NFT listings with a single signature, improving seller experience.
## Limits and Constraints
| Constraint | Value | Notes |
| -------------------- | -------------------------------------------------- | -------------------------------------------- |
| **Maximum listings** | 20 per transaction | Batch into multiple transactions if needed |
| **Wallet type** | EOA: 1 signature Smart contract: N signatures | Passport wallets need multiple confirmations |
| **Approval** | Per collection | One-time approval per NFT collection |
## How It Works
```mermaid theme={null}
sequenceDiagram
User->>SDK: prepareBulkListings(20 items)
SDK-->>User: completeListings()
User->>Wallet: Sign message
Wallet-->>User: Signature
User->>SDK: completeListings(sig)
SDK->>Orderbook: Submit all
Orderbook-->>SDK: Results
```
The `prepareBulkListings()` call returns:
1. **Actions** - Approval transactions (if needed) + `signable` message
2. **completeListings()** method - Scoped method to submit signatures
```typescript theme={null}
const { actions, completeListings } = await sdk.prepareBulkListings({
makerAddress: `userAddress`,
listingParams: [...], // Array of listing configs
});
```
## Basic Example
Create multiple ERC-721 listings at once:
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
import { Environment } from '@imtbl/config';
import { ethers } from 'ethers';
async function createBulkListings() {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const `userAddress` = await signer.getAddress();
// Define multiple listings
const `listingParams` = [
{
sell: {
type: 'ERC721',
contractAddress: '0x123...',
tokenId: '1',
},
buy: {
type: 'NATIVE',
amount: '1000000000000000000', // 1 IMX
},
makerFees: [{
`recipientAddress`: MARKETPLACE_WALLET,
amount: '10000000000000000', // 0.01 IMX
}],
},
{
sell: {
type: 'ERC721',
contractAddress: '0x123...',
tokenId: '2',
},
buy: {
type: 'NATIVE',
amount: '2000000000000000000', // 2 IMX
},
makerFees: [{
`recipientAddress`: MARKETPLACE_WALLET,
amount: '20000000000000000', // 0.02 IMX
}],
},
// ... up to 20 listings total
];
// 1. Prepare bulk listings
const { actions, completeListings } = await sdk.prepareBulkListings({
makerAddress: `userAddress`,
`listingParams`,
});
// 2. Handle approval transactions (one per collection)
for (const action of actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
console.log('Requesting approval for collection...');
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
await txResponse.wait();
console.log('Approved!');
}
}
// 3. Sign the bulk listing message
const `signableAction` = actions.find(
(action) => action.type === orderbook.ActionType.SIGNABLE
);
if (`signableAction`) {
const signature = await signer._signTypedData(
signableAction.message.domain,
signableAction.message.types,
signableAction.message.value
);
// 4. Submit all listings with the signature
const results = await completeListings(signature);
console.log('Created listings:', {
successful: results.result.filter(r => r.success).length,
failed: results.result.filter(r => !r.success).length,
});
return results;
}
}
```
## Mixed Token Types
Create listings for both ERC-721 and ERC-1155 tokens:
```typescript theme={null}
const `listingParams` = [
// ERC-721 listing
{
sell: {
type: 'ERC721',
contractAddress: '0xAAA...',
tokenId: '123',
},
buy: {
type: 'NATIVE',
amount: '1000000000000000000',
},
makerFees: [],
},
// ERC-1155 listing
{
sell: {
type: 'ERC1155',
contractAddress: '0xBBB...',
tokenId: '456',
amount: '10', // Selling 10 copies
},
buy: {
type: 'NATIVE',
amount: '5000000000000000000', // 5 IMX total
},
makerFees: [],
},
];
```
## Smart Contract Wallets (Passport)
**[Passport](/docs/products/passport/wallet) & Smart Contract Wallets**
When using smart contract wallets like [Passport](/docs/products/passport/wallet), users must sign **separate confirmations for each listing**. Unlike EOA wallets (MetaMask) which can sign once for all 20 listings, smart contract wallets require individual signatures.
This is due to smart contract wallet architecture and cannot be batched.
## Response Structure
The `completeListings()` method returns success/failure for each listing:
```typescript theme={null}
const results = await completeListings(signature);
console.log(results.result);
// [
// { success: true, order: {...}, `order_hash`: "0x..." },
// { success: true, order: {...}, `order_hash`: "0x..." },
// { success: false, reason: "INSUFFICIENT_BALANCE" },
// ]
```
Handle partial failures gracefully:
```typescript theme={null}
const successful = results.result.filter(r => r.success);
const failed = results.result.filter(r => !r.success);
console.log(`Created ${successful.length} listings`);
if (failed.length > 0) {
console.warn(`❌ Failed to create ${failed.length} listings:`);
failed.forEach((f, i) => {
console.log(`- Listing ${i}: ${f.reason}`);
});
}
```
***
# Bulk Order Fulfillment (Shopping Cart)
Buy multiple NFTs in a single transaction—the foundation of marketplace shopping carts.
## Limits and Constraints
| Constraint | Value | Notes |
| --------------------------- | ------------------------ | ---------------------------------------- |
| **Maximum orders** | 50 per transaction | Batch into multiple if needed |
| **Currency consistency** | All orders same currency | Enforce on frontend |
| **Best-effort fulfillment** | Enabled by default | Fills available orders even if some fail |
## How It Works
```mermaid theme={null}
flowchart TD
A[50 Orders] --> B[Validate]
B --> C[Fulfillable: 40]
B --> D[Unfulfillable: 10]
C --> E{Balance OK?}
E -->|Yes| F[Execute]
E -->|No| G[Partial Fill]
F --> H[Results]
G --> H
```
Bulk fulfillment handles common shopping cart scenarios:
* Some orders already filled by others
* Some orders cancelled
* Insufficient balance for all items
* Orders with mixed availability
The SDK provides:
1. **fulfillableOrders** - Can be executed
2. **unfulfillableOrders** - Cannot be executed (with reasons)
3. **sufficientBalance** - Whether user can afford fulfillable orders
4. **Actions** - Transactions to execute
## Basic Example
Simple shopping cart checkout:
```typescript theme={null}
async function checkoutCart(orderIds: string[]) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const buyerAddress = await signer.getAddress();
// Prepare bulk fulfillment
const { result } = await sdk.fulfillBulkOrders({
orderIds,
takerAddress: buyerAddress,
takerFees: [
{
recipientAddress: MARKETPLACE_WALLET,
amount: '50000000000000000', // 0.05 IMX marketplace fee
},
],
});
console.log('Cart Analysis:', {
fulfillable: result.fulfillableOrders.length,
unfulfillable: result.unfulfillableOrders.length,
hasSufficientBalance: result.sufficientBalance,
});
// Check if user can afford fulfillable orders
if (!result.sufficientBalance) {
const totalCost = result.fulfillableOrders.reduce(
(sum, order) => sum + BigInt(order.buy.amount),
0n
);
console.error(`Insufficient balance. Need: ${ethers.utils.formatEther(totalCost)} IMX`);
return;
}
// Execute all actions
for (const action of result.actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
console.log(`Transaction sent: ${txResponse.hash}`);
await txResponse.wait();
}
}
console.log('Purchased', result.fulfillableOrders.length, 'NFTs!');
}
```
## Response Structure
The response categorizes orders:
```typescript theme={null}
{
result: {
fulfillableOrders: [
{
order_id: "order-1",
buy: { type: "NATIVE", amount: "1000000000000000000" },
sell: { type: "ERC721", contract_address: "0x...", token_id: "1" }
},
{
order_id: "order-2",
buy: { type: "NATIVE", amount: "2000000000000000000" },
sell: { type: "ERC721", contract_address: "0x...", token_id: "2" }
}
],
unfulfillableOrders: [
{
order_id: "order-3",
reason: "FILLED" // Already sold
},
{
order_id: "order-4",
reason: "CANCELLED" // Seller cancelled
}
],
sufficientBalance: true,
actions: [
{ type: "TRANSACTION", purpose: "APPROVAL" },
{ type: "TRANSACTION", purpose: "FULFILL_ORDER" }
],
expiration: "2024-01-15T10:30:00Z"
}
}
```
## UX Strategies for Unavailable Items
When some cart items become unavailable, choose a user experience strategy:
### Strategy 1: All or Nothing
Require users to fix cart before checkout:
```typescript theme={null}
const { result } = await sdk.fulfillBulkOrders({
orderIds: cartOrderIds,
takerAddress: buyerAddress,
takerFees: [],
});
if (result.unfulfillableOrders.length > 0) {
// Show user which items are unavailable
console.error('Some items are no longer available:');
result.unfulfillableOrders.forEach(order => {
console.log(`- Order ${order.order_id}: ${order.reason}`);
});
throw new Error('Please remove unavailable items from cart');
}
// All items available, proceed with checkout
executeActions(result.actions);
```
### Strategy 2: Best Effort
Automatically checkout available items without user intervention:
```typescript theme={null}
const { result } = await sdk.fulfillBulkOrders({
orderIds: cartOrderIds,
takerAddress: buyerAddress,
takerFees: [],
});
// Show summary
console.log(`Purchasing ${result.fulfillableOrders.length} of ${cartOrderIds.length} items`);
if (result.unfulfillableOrders.length > 0) {
console.log('The following items were removed from your cart:');
result.unfulfillableOrders.forEach(order => {
console.log(`- ${order.reason}`);
});
}
// Proceed with available items
if (result.fulfillableOrders.length > 0) {
await executeActions(result.actions);
}
```
### Strategy 3: Hybrid (Recommended)
Ask user to confirm before proceeding with partial cart:
```typescript theme={null}
const { result } = await sdk.fulfillBulkOrders({
orderIds: cartOrderIds,
takerAddress: buyerAddress,
takerFees: [],
});
if (result.unfulfillableOrders.length > 0) {
const message = `
${result.unfulfillableOrders.length} items are no longer available.
Would you like to purchase the remaining ${result.fulfillableOrders.length} items?
`;
const shouldProceed = confirm(message);
if (!shouldProceed) {
console.log('Purchase cancelled by user');
return;
}
}
// User confirmed or all items available
await executeActions(result.actions);
```
## Insufficient Balance Handling
When user can't afford fulfillable items:
```typescript theme={null}
const { result } = await sdk.fulfillBulkOrders({
orderIds: cartOrderIds,
takerAddress: buyerAddress,
takerFees: [],
});
if (!result.sufficientBalance) {
// Calculate how much they need
const totalCost = result.fulfillableOrders.reduce(
(sum, order) => sum + BigInt(order.buy.amount),
0n
);
// Get user's current balance
const balance = await provider.getBalance(buyerAddress);
const shortfall = totalCost - balance;
console.error(`Insufficient Balance:
Need: ${ethers.utils.formatEther(totalCost)} IMX
Have: ${ethers.utils.formatEther(balance)} IMX
Short: ${ethers.utils.formatEther(shortfall)} IMX
`);
// Optionally: suggest removing most expensive items
const sortedByPrice = result.fulfillableOrders
.sort((a, b) => BigInt(b.buy.amount) - BigInt(a.buy.amount));
console.log('Consider removing:', sortedByPrice[0].order_id);
return;
}
// Has sufficient balance, proceed
```
## Currency Consistency Requirement
**All orders must use the same currency**
You cannot mix NATIVE and ERC-20 orders in a single bulk fulfillment. Enforce this on your frontend:
```typescript theme={null}
function validateCartCurrency(orders: Order[]): boolean {
if (orders.length === 0) return true;
const firstCurrency = orders[0].buy.type;
const firstContract = orders[0].buy.contract_address;
return orders.every(order => {
if (order.buy.type !== firstCurrency) return false;
if (firstCurrency === 'ERC20' && order.buy.contract_address !== firstContract) {
return false;
}
return true;
});
}
// Before checkout
if (!validateCartCurrency(cartItems)) {
throw new Error('All items must be priced in the same currency');
}
```
## Partial Fills with Bulk Orders
Specify `amountToFill` for ERC-1155 partial fills:
```typescript theme={null}
const { result } = await sdk.fulfillBulkOrders({
orderIds: ['order-1', 'order-2'],
takerAddress: buyerAddress,
takerFees: [],
amountToFill: {
'order-1': '5', // Buy 5 of available ERC-1155 items
'order-2': undefined, // Full fill
},
});
```
If requesting more than available, best-effort fills up to max:
```typescript theme={null}
// Order has 5 items available
amountToFill: {
'order-1': '10', // Request 10
// Will fill only 5 (max available)
}
```
## Complete Shopping Cart Example
Full implementation with all strategies:
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
import { Environment } from '@imtbl/config';
import { ethers } from 'ethers';
interface CartItem {
orderId: string;
nftName: string;
price: string;
currency: string;
}
async function checkoutShoppingCart(cart: CartItem[]) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const buyerAddress = await signer.getAddress();
try {
// 1. Validate cart
if (cart.length === 0) {
throw new Error('Cart is empty');
}
if (cart.length > 50) {
throw new Error('Maximum 50 items per checkout');
}
// 2. Calculate marketplace fee (1% of total)
const totalValue = cart.reduce(
(sum, item) => sum + BigInt(item.price),
0n
);
const marketplaceFee = (totalValue * 100n) / 10000n;
// 3. Prepare bulk fulfillment
console.log('Analyzing cart...');
const { result } = await sdk.fulfillBulkOrders({
orderIds: cart.map(item => item.orderId),
takerAddress: buyerAddress,
takerFees: [{
recipientAddress: MARKETPLACE_WALLET,
amount: marketplaceFee.toString(),
}],
});
// 4. Handle unavailable items
if (result.unfulfillableOrders.length > 0) {
console.warn(`⚠️ ${result.unfulfillableOrders.length} items unavailable:`);
result.unfulfillableOrders.forEach(order => {
const item = cart.find(c => c.orderId === order.order_id);
console.log(`- ${item?.nftName}: ${order.reason}`);
});
// Ask user to confirm
const shouldProceed = confirm(
`Proceed with ${result.fulfillableOrders.length} available items?`
);
if (!shouldProceed) {
console.log('Checkout cancelled');
return;
}
}
// 5. Check balance
if (!result.sufficientBalance) {
const fulfillableCost = result.fulfillableOrders.reduce(
(sum, order) => sum + BigInt(order.buy.amount),
0n
);
const balance = await provider.getBalance(buyerAddress);
alert(`Insufficient balance
Need: ${ethers.utils.formatEther(fulfillableCost)} IMX
Have: ${ethers.utils.formatEther(balance)} IMX
`);
return;
}
// 6. Show final summary
const fulfillableCost = result.fulfillableOrders.reduce(
(sum, order) => sum + BigInt(order.buy.amount),
0n
);
console.log('Final Checkout:', {
items: result.fulfillableOrders.length,
subtotal: ethers.utils.formatEther(fulfillableCost),
marketplaceFee: ethers.utils.formatEther(marketplaceFee),
total: ethers.utils.formatEther(fulfillableCost + marketplaceFee),
expires: new Date(result.expiration),
});
// 7. Execute all actions
console.log('Processing payment...');
for (const action of result.actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
console.log(`Executing: ${action.purpose}`);
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
console.log(`Transaction sent: ${txResponse.hash}`);
const receipt = await txResponse.wait();
console.log(`Confirmed in block ${receipt.blockNumber}`);
}
}
console.log('Purchase complete!');
return {
purchased: result.fulfillableOrders.length,
unavailable: result.unfulfillableOrders.length,
transactionHash: result.actions[result.actions.length - 1].transactionHash,
};
} catch (error: any) {
console.error('Checkout failed:', error.message);
throw error;
}
}
```
## Best Practices
### For Bulk Listings
1. **Batch wisely:** Keep under 20 listings per batch
2. **Handle approvals:** Cache approval status per collection
3. **Smart contract wallets:** Warn users about multiple signatures
4. **Error handling:** Gracefully handle partial failures
5. **Status updates:** Poll for PENDING → ACTIVE transitions
### For Bulk Fulfillment
1. **Currency enforcement:** Validate same currency on frontend
2. **Real-time availability:** Refresh cart items before checkout
3. **Balance checks:** Verify sufficient funds before transaction
4. **User communication:** Clearly explain unavailable items
5. **Expiration warning:** Show 3-minute countdown timer
6. **Retry logic:** Handle race conditions gracefully
7. **Gas estimation:** Show estimated gas cost for transparency
## Next Steps
Single listing creation guide
Query bulk-created orders
Single order fulfillment guide
Soft and hard cancellation
Understanding marketplace fees
# Cancel Orders
Source: https://docs.immutable.com/docs/products/orderbook/cancel-orders
Complete guide to cancelling NFT orders with soft (gasless) and hard (on-chain) cancellation
After [creating listings](/docs/products/orderbook/create-listings) or reviewing [filled orders](/docs/products/orderbook/fill-orders), you may need to cancel them. Learn how to cancel NFT orders using both soft (gasless) and hard (on-chain) cancellation methods.
See [Getting Started](/docs/products/orderbook/overview#getting-started) for prerequisites and installation. For cancellation, you also need the Order ID(s) and must own the orders.
## Trade Execution Architecture
Immutable offers a centralized orderbook but doesn't act as an intermediary during trades. Instead, trades settle through a smart contract, ensuring secure peer-to-peer trading.
To understand cancellation, first understand how trades execute:
```mermaid theme={null}
sequenceDiagram
Buyer->>Orderbook: 1. Request fulfillment
Note over Orderbook: 2. Prepare payload (90s)
Orderbook-->>Buyer: Transaction details
Buyer->>Contract: 3. Submit signed tx
Contract->>Contract: 4. Execute trade
Note over Orderbook: Soft Cancel: Step 2
Note over Contract: Hard Cancel: Step 4
```
### 5-Step Trade Flow
1. **Buyer selects order** - User chooses a listing via marketplace
2. **Orderbook prepares payload** - Immutable generates transaction details for settlement contract
3. **Buyer signs transaction** - User reviews and signs
4. **Transaction submitted** - Signed transaction sent to settlement contract
5. **Settlement executes** - Contract swaps assets if all details valid
This creates **two cancellation opportunities**:
| Cancel Type | Intercepts At | Gas Cost | Definitive? |
| --------------- | ---------------------------- | --------- | ----------------------- |
| **Soft Cancel** | Step 2 (orderbook) | Free | No - 90s race condition |
| **Hard Cancel** | Step 5 (settlement contract) | Costs gas | Yes - guaranteed |
**Why Two Methods?**
* **Soft cancel** prevents the orderbook from providing transaction details (Step 2). Gasless but has race condition.
* **Hard cancel** blacklists the order in the settlement contract (Step 5). Costs gas but is definitive—even if someone has a signed transaction, it will fail.
For complete order status progression, see the [Order Lifecycle](/docs/products/orderbook/overview#order-lifecycle) documentation.
## Soft Cancel (Gasless)
Soft cancellation is free and instant—the order is marked as cancelled off-chain, preventing new `fulfillments`.
### How It Works
1. User signs a message proving they own the order (EIP-712)
2. Orderbook marks order as cancelled
3. Orderbook stops generating transaction details for this order
4. Order becomes unfillable by new buyers
### Basic Example
```typescript theme={null}
import { orderbook } from '@imtbl/sdk';
async function softCancelOrders(`orderIds`: string[]) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const `userAddress` = await signer.getAddress();
const { result } = await sdk.cancelOrders(`orderIds`, `userAddress`);
console.log({
successful: result.successful_cancellations,
pending: result.`pending_cancellations`, // ⚠️ May still execute
failed: result.failed_cancellations,
});
}
```
### Response Structure
The response categorizes cancellations into three arrays:
```typescript theme={null}
{
"result": {
"successful_cancellations": [
"018a8c71-d7e4-e303-a2ef-318871ef7756",
"458a8c71-d7e4-e303-a2ef-318871ef7778"
],
"`pending_cancellations`": [
"238a8c71-d7e4-e303-a2ef-318871ef7778" // ⚠️ Active transaction in progress
],
"failed_cancellations": [
{
"order": "458a8c71-d7e4-e303-a2ef-318871ef7790",
"reason_code": "FILLED" // Already filled
}
]
}
}
```
**Understanding `pending_cancellations`**
Orders in `pending_cancellations` have an **active transaction payload** already issued to a buyer. These orders **may still execute** even though your cancel was accepted by the orderbook.
This happens when:
1. Buyer started fulfillment process before your cancel
2. Buyer received transaction details (Step 2) before cancel
3. Buyer has 90 seconds to submit the signed transaction
If you see orders in `pending_cancellations`, consider using a [hard cancel](#hard-cancel-on-chain) for guaranteed cancellation.
### The 90-Second Race Condition
**Critical: Race Condition Window**
The execution window is **90 seconds from when the orderbook provides transaction details** (Step 2), NOT from when you initiated the cancel.
**Timeline:**
```
T=0s Buyer starts fulfillment, gets transaction data
T=30s You soft cancel the order
T=60s Order marked as cancelled in orderbook
T=90s Transaction data expires
```
During the 90-second window, your asset may still be exchanged at the previously agreed price if the buyer submits their signed transaction.
**The race condition exists for LESS than 90 seconds** from when the cancel was accepted—but you don't control when the buyer started Step 2.
### Validating Soft Cancel
Poll the order to confirm status transitions to `CANCELLED`:
```typescript theme={null}
async function validateCancellation(orderId: string) {
let attempts = 0;
while (attempts < 10) {
const { result: order } = await sdk.getListing(orderId);
if (order.status.name === 'CANCELLED') {
console.log('Order cancelled!', {
cancellationType: order.status.cancellation_type, // 'OFF_CHAIN'
});
return true;
}
if (order.status.name === 'FILLED') {
console.log('❌ Order was filled before cancel took effect');
return false;
}
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1s
attempts++;
}
console.log('⏳ Cancellation still processing...');
return false;
}
```
### Limits
**Batch Limits:** You can cancel up to **20 orders** in a single soft cancel transaction. For more than 20, batch them into multiple requests.
```typescript theme={null}
// Cancel up to 20 orders at once
const `orderIds` = [
'order-1', 'order-2', 'order-3', /* ... up to 20 ... */
];
const { result } = await sdk.cancelOrders(`orderIds`, `userAddress`);
```
## Hard Cancel (On-Chain)
Hard cancellation provides **definitive cancellation** by updating the settlement contract's blacklist. Costs gas but eliminates race conditions.
### How It Works
1. User sends transaction to settlement contract
2. Contract adds order to blacklist
3. Any future fulfillment attempts for this order will fail on-chain
4. Even if someone has a signed transaction, contract rejects it
### When to Use Hard Cancel
| Scenario | Recommended Cancel Type |
| --------------------------------- | ----------------------- |
| High-value NFT (>\$1000) | Hard cancel |
| Suspicious buyer activity | Hard cancel |
| Algorithmic bot trading | Hard cancel |
| Order has `pending_cancellations` | Hard cancel |
| Regular user, low-value item | Soft cancel |
| Want to save gas | Soft cancel |
```mermaid theme={null}
flowchart TD
A[Cancel Order] --> B{Value >$1000?}
B -->|Yes| C[Hard Cancel]
B -->|No| D{Bot/Trader?}
D -->|Yes| C
D -->|No| E{Pending txs?}
E -->|Yes| C
E -->|No| F[Soft Cancel]
```
### Basic Example
```typescript theme={null}
async function hardCancelOrders(`orderIds`: string[]) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
// Prepare on-chain cancellation
const { `signableAction` } = await sdk.prepareOrderCancellations(`orderIds`);
// Execute on-chain (costs gas)
const `txResponse` = await signer.sendTransaction({
to: `signableAction`.to,
data: `signableAction`.data,
});
console.log('Transaction sent:', `txResponse`.hash);
const receipt = await `txResponse`.wait();
console.log('Hard cancel confirmed in block', receipt.blockNumber);
return receipt;
}
```
### Response
Hard cancel returns transaction details:
```typescript theme={null}
{
"type": 2,
"chainId": 31337,
"to": "0x0165878A594ca255338adfa4d48449f69242Eb8F", // Settlement contract
"data": "0xfd9f1e14f7f8cb7730d62bf4b15ecff270857...",
"gasLimit": { "hex": "0xd821" },
"hash": "0x3ad4833ff47ddef5982746935cdcf555631676e097e4e64218c593664f478e7a"
}
```
### Validating Hard Cancel
Poll the order after transaction confirms:
```typescript theme={null}
async function validateHardCancel(orderId: string, `txReceipt`: any) {
console.log('Transaction confirmed, waiting for orderbook to process...');
let attempts = 0;
while (attempts < 20) {
const { result: order } = await sdk.getListing(orderId);
if (order.status.name === 'CANCELLED') {
console.log('Hard cancel processed!', {
cancellationType: order.status.cancellation_type, // 'ON_CHAIN'
});
return true;
}
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
attempts++;
}
console.log('⏳ Still processing on-chain events...');
return false;
}
```
**Async Status Updates:** After the transaction confirms, Immutable services must detect the on-chain event and update the off-chain orderbook. This usually takes a few seconds but can take longer during network congestion.
## Complete Cancellation Flow
Full example with error handling and user choice:
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
import { Environment } from '@imtbl/config';
import { ethers } from 'ethers';
async function cancelOrderFlow(
orderIds: string[],
cancelType: 'soft' | 'hard' = 'soft'
) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const userAddress = await signer.getAddress();
try {
if (cancelType === 'soft') {
// Soft cancel (gasless)
console.log('Initiating soft cancel...');
const { result } = await sdk.cancelOrders(`orderIds`, `userAddress`);
console.log({
successful: result.successful_cancellations.length,
pending: result.pending_cancellations.length,
failed: result.failed_cancellations.length,
});
// Warn about pending cancellations
if (result.pending_cancellations.length > 0) {
console.warn('⚠️ Some orders have pending transactions:',
result.pending_cancellations
);
console.warn('Consider hard cancel for guaranteed cancellation');
// Optionally upgrade to hard cancel
const shouldUpgrade = confirm(
'Some orders may still execute. Upgrade to hard cancel (costs gas)?'
);
if (shouldUpgrade) {
return cancelOrderFlow(result.pending_cancellations, 'hard');
}
}
// Validate cancellations
for (const orderId of result.successful_cancellations) {
await validateCancellation(orderId);
}
return result;
} else {
// Hard cancel (costs gas)
console.log('Initiating hard cancel...');
const { signableAction } = await sdk.prepareOrderCancellations(orderIds);
// Show gas estimate to user
const gasEstimate = await provider.estimateGas({
to: signableAction.to,
data: signableAction.data,
});
const gasPrice = await provider.getGasPrice();
const estimatedCost = gasEstimate.mul(gasPrice);
console.log('Estimated gas cost:', ethers.utils.formatEther(estimatedCost), 'IMX');
// Execute transaction
const txResponse = await signer.sendTransaction({
to: signableAction.to,
data: signableAction.data,
});
console.log('Transaction sent:', txResponse.hash);
const receipt = await txResponse.wait();
console.log('Confirmed in block', receipt.blockNumber);
// Validate cancellation
for (const orderId of orderIds) {
await validateHardCancel(orderId, receipt);
}
return { receipt, orderIds };
}
} catch (error: any) {
console.error('Cancellation failed:', error.message);
if (error.message.includes('not owner')) {
console.error('❌ You do not own these orders');
} else if (error.message.includes('FILLED')) {
console.error('❌ Order already filled');
} else if (error.message.includes('gas')) {
console.error('❌ Insufficient gas for hard cancel');
}
throw error;
}
}
```
## Decision Matrix: Soft vs Hard
Choose the right cancellation method based on your scenario:
```typescript theme={null}
function decideCancellationType(order: any, `userType`: 'casual' | 'trader' | 'bot') {
// Get order value in USD (example)
const orderValueUSD = calculateUSDValue(order.buy.amount);
// High-value orders: always hard cancel
if (orderValueUSD > 1000) {
return 'hard';
}
// Bots need certainty: always hard cancel
if (`userType` === 'bot') {
return 'hard';
}
// Check if order is expiring soon
const `expiresIn` = new Date(order.end_at).getTime() - Date.now();
if (`expiresIn` < 60000) { // Less than 1 minute
return 'soft'; // Will expire naturally, save gas
}
// Active traders willing to pay gas
if (`userType` === 'trader' && orderValueUSD > 100) {
return 'hard';
}
// Default: soft cancel for casual users
return 'soft';
}
```
## Error Handling
Common errors during cancellation:
| Error | Cause | Solution |
| -------------------- | -------------------------------- | -------------------- |
| `NOT_OWNER` | User doesn't own the order | Verify ownership |
| `ORDER_FILLED` | Order already filled | Remove from UI |
| `ORDER_CANCELLED` | Order already cancelled | Remove from UI |
| `INSUFFICIENT_GAS` | Not enough gas for hard cancel | Request funding |
| `TRANSACTION_FAILED` | Hard cancel transaction reverted | Check network, retry |
## Best Practices
1. **Check `pending_cancellations`:** Warn users about race conditions
2. **Show gas estimates:** For hard cancels, display gas cost before execution
3. **Poll for status:** Don't assume immediate cancellation
4. **Batch cancels:** Group multiple soft cancels into one request (max 20)
5. **Upgrade path:** Offer hard cancel if soft cancel returns pending
6. **Expiration check:** If order expires soon, soft cancel sufficient
7. **Value-based logic:** Use hard cancel for high-value orders automatically
## Understanding Gas
**What is Gas?**
Gas is the computational fee required to perform blockchain transactions. It serves two purposes:
1. **Incentivize `Validators`:** Pays network `validators` to include your transaction
2. **Prevent Spam:** Makes it costly to abuse the network
**Who Pays Gas in Orderbook:**
* **Creating orders:** Free for both buyers and sellers (gasless signatures)
* **Filling orders:** Buyers pay gas to execute the trade
* **Canceling orders:**
* Soft cancel: Free (gasless)
* Hard cancel: Sellers pay gas for on-chain cancellation
This is why soft cancels are recommended for casual users—they preserve the gasless experience.
## Next Steps
Learn how to create NFT listings
Buy NFTs by filling orders
Query and manage order status
Cancel multiple orders efficiently
Understand gas costs for hard cancels
# Collection Bids
Source: https://docs.immutable.com/docs/products/orderbook/collection-bids
Place bids on **any** NFT within a collection, rather than a specific token. Useful for buyers who want to acquire any item from a collection at a certain price.
See [Getting Started](/docs/products/orderbook/overview#getting-started) for prerequisites and installation.
Need to target **only NFTs whose metadata matches certain traits** (for example, `Background` is `Blue` or `Red`)? Use a **[trait bid](/docs/products/orderbook/trait-bids)** instead: same criteria-style collection offer, but Immutable validates the seller’s `tokenId` against your trait filters at fulfillment time.
## Creating a Collection Bid
```typescript theme={null}
import { Orderbook, ActionType } from '@imtbl/orderbook';
const prepared = await orderbook.prepareCollectionBid({
makerAddress: address,
buy: {
type: 'ERC721_COLLECTION',
contractAddress: NFT_CONTRACT,
amount: '1', // Bidding on 1 NFT from the collection
},
sell: {
type: 'ERC20',
contractAddress: '0x...', // IMX ERC20 token address
amount: '500000000000000000', // Offering 0.5 IMX
},
});
for (const action of prepared.actions) {
if (action.type === ActionType.TRANSACTION) {
const tx = await action.buildTransaction();
await walletClient.sendTransaction(tx);
}
}
const signable = prepared.actions.find((a) => a.type === ActionType.SIGNABLE)!;
const signature = await walletClient.signTypedData({
account: address,
domain: signable.message.domain,
types: signable.message.types,
primaryType: 'OrderComponents',
message: signable.message.value,
});
const { result } = await orderbook.createCollectionBid({
orderComponents: prepared.orderComponents,
orderHash: prepared.orderHash,
orderSignature: signature,
makerFees: [],
});
console.log('Collection bid created:', result.id);
```
## Bidding on Multiple NFTs
Bid on multiple items from a collection in a single order:
```typescript theme={null}
const preparedMulti = await orderbook.prepareCollectionBid({
makerAddress: address,
buy: {
type: 'ERC721_COLLECTION',
contractAddress: NFT_CONTRACT,
amount: '5', // Bidding on 5 NFTs
},
sell: {
type: 'ERC20',
contractAddress: '0x...', // IMX ERC20 token address
amount: '2500000000000000000', // 0.5 IMX each = 2.5 IMX total
},
});
// Then sign `preparedMulti.actions` and call `createCollectionBid` the same way as the single-NFT example above.
```
## Accepting Collection Bids (Sellers)
Sellers can fill collection bids by selecting which token to sell:
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
// Fulfill a collection bid with a specific token (5th argument is tokenId for criteria-based orders)
const { actions } = await orderbook.fulfillOrder(
collectionBidId,
`sellerAddress`,
[], // taker fees
undefined, // amountToFill — omit for ERC-721 collection fills
'123', // tokenId — specific token the seller is selling into the bid
);
// Execute all required actions
for (const action of actions) {
if (action.type === 'TRANSACTION') {
const unsignedTx = await action.buildTransaction();
const hash = await walletClient.sendTransaction(unsignedTx);
await publicClient.waitForTransactionReceipt({ hash });
}
}
```
## Querying Collection Bids
### List Collection Bids for a Contract
```typescript theme={null}
import { Orderbook, OrderStatusName } from '@imtbl/orderbook';
const { result } = await orderbook.listCollectionBids({
buyItemContractAddress: NFT_CONTRACT,
status: OrderStatusName.ACTIVE,
pageSize: 50,
});
for (const bid of result) {
const pricePerItem = BigInt(bid.sell.amount) / BigInt(bid.buy.amount);
console.log(`Bid for ${bid.buy.amount} NFTs at ${pricePerItem} IMX each`);
}
```
### Get Best Collection Bid
Find the highest offer for any NFT in a collection:
```typescript theme={null}
const { result } = await orderbook.listCollectionBids({
buyItemContractAddress: NFT_CONTRACT,
status: OrderStatusName.ACTIVE,
sortBy: 'sell_item_amount',
sortDirection: 'desc',
pageSize: 1,
});
if (result.length > 0) {
console.log('Best collection bid:', result[0].sell.amount, 'IMX');
}
```
## Use Cases
| Scenario | Implementation |
| ---------------------- | -------------------------------------------- |
| **Floor sweeping** | Bid on multiple NFTs at floor price |
| **Portfolio building** | Acquire any item from desired collections |
| **Arbitrage** | Bid below market value across collections |
| **Instant liquidity** | Sellers can instantly sell to highest bidder |
## Comparison: Token Bids vs Collection Bids vs Trait Bids
| Aspect | Token Bid | Collection Bid | Trait bid |
| -------------------- | ------------------------ | --------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Target** | Specific token ID | Any token in collection | Tokens in collection whose **metadata** matches [trait filters](/docs/products/orderbook/trait-bids) |
| **Use case** | Want one known asset | Want any item from the collection | Want items matching attributes (e.g. rarity + background) |
| **Fill flexibility** | Only that token can fill | Seller chooses which token | Seller chooses `tokenId`; must satisfy trait criteria |
| **Price discovery** | Token-level | Collection-level | Filtered subset of collection |
## Next Steps
Alternative: Sell NFTs via listings
Sellers: Accept collection bids
Cancel collection bids (soft/hard)
Query and track your bids
Bids constrained by NFT metadata traits
Bids on tokens sharing a specific metadata ID
# Create Listings
Source: https://docs.immutable.com/docs/products/orderbook/create-listings
Comprehensive guide to creating NFT listings using the Immutable Orderbook SDK
Learn how to create NFT listings for both ERC-721 and ERC-1155 tokens using the TypeScript SDK. This guide covers the complete flow from preparation to submission.
See [Getting Started](/docs/products/orderbook/overview#getting-started) for prerequisites and installation.
## Quick Overview
Creating a listing involves 4 steps:
1. **Prepare listing** - SDK generates order components and typed data
2. **Sign & submit approval** (if needed) - One-time per collection
3. **Sign typed data** - User signs the listing (gasless)
4. **Create listing** - Submit signed order to orderbook
## Creating an ERC-721 Listing
ERC-721 tokens are unique (1-of-1). Each listing sells exactly one token.
### Prepare the Listing
```typescript theme={null}
import { orderbook } from '@imtbl/sdk';
const listing = await sdk.prepareListing({
makerAddress: userAddress,
sell: {
type: 'ERC721',
contractAddress: '0x...', // NFT contract
tokenId: '123',
// amount is always 1 for ERC721, no need to specify
},
buy: {
type: 'NATIVE', // or 'ERC20'
amount: '1000000000000000000', // 1 IMX in wei
// For ERC20:
// contractAddress: '0x...',
},
makerFees: [
{
recipientAddress: marketplaceWallet,
amount: '10000000000000000', // 0.01 IMX marketplace fee
},
],
});
console.log({
actions: listing.actions, // Actions to execute
orderComponents: listing.orderComponents, // Order data
orderHash: listing.orderHash, // Order ID
});
```
**Order Type:** ERC-721 listings automatically use `FULL_RESTRICTED` order type, meaning they cannot be partially filled. The entire NFT must be purchased at once.
### Handle Approval Transaction
For the general approval pattern, see [Overview: Approval Pattern](/docs/products/orderbook/overview#approval-pattern).
If this is the user's first listing for this NFT collection, they must approve the Seaport contract:
```typescript theme={null}
for (const action of listing.actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
// This is the approval transaction
const txHash = await signer.sendTransaction({
to: action.to,
data: action.data,
});
// Wait for confirmation
await txHash.wait();
console.log('Approval confirmed');
}
}
```
**One-time approval:** Users only need to approve once per NFT collection. Subsequent listings for the same collection skip this step.
**Royalty Enforcement:** Approving the Seaport contract enables enforced royalties. For more details, see the [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist) documentation.
### Sign the Order
After approval, user signs the typed data (gasless):
```typescript theme={null}
// Find the signable action
const `signableAction` = listing.actions.find(
(action) => action.type === orderbook.ActionType.SIGNABLE
);
if (`signableAction`.purpose === orderbook.SignablePurpose.CREATE_LISTING) {
const signature = await signer._signTypedData(
signableAction.message.domain,
signableAction.message.types,
signableAction.message.value
);
console.log('Order signed:', signature);
}
```
### Submit the Listing
Finally, submit the signed order to the orderbook:
```typescript theme={null}
const { result } = await sdk.createListing({
orderComponents: listing.orderComponents,
orderHash: listing.orderHash,
orderSignature: signature,
makerFees: listing.makerFees,
});
console.log({
id: result.id, // Order ID
status: result.status.name, // Initially 'PENDING'
accountAddress: result.account_address,
});
```
**Status Transitions:** Listings start as `PENDING` while the orderbook validates balance and approval. They transition to `ACTIVE` asynchronously (usually within seconds). Build your UI to handle this delay optimistically.
## Creating an ERC-1155 Listing
ERC-1155 tokens support multiple copies. Listings can sell any quantity and support partial fills.
### Prepare the Listing
```typescript theme={null}
const listing = await sdk.prepareListing({
makerAddress: userAddress,
sell: {
type: 'ERC1155',
contractAddress: '0x...', // NFT contract
tokenId: '456',
amount: '10', // Selling 10 copies
},
buy: {
type: 'NATIVE',
amount: '5000000000000000000', // 5 IMX total (0.5 IMX per item)
// Buy amount MUST be a multiple of sell amount!
},
makerFees: [
{
recipientAddress: marketplaceWallet,
amount: '50000000000000000', // 0.05 IMX total fee
},
],
});
```
**Buy Amount Rule:** For ERC-1155, the `buy.amount` must be a **multiple** of `sell.amount`.
**Example:**
* Selling `10` items
* Buy amount can be: `10`, `20`, `30`, etc. (enables 1, 2, 3+ items per fill)
* Buy amount of `15` would be invalid (not a multiple of 10)
This enables partial fills: buyers can purchase 1, 2, 5, or all 10 items.
**Order Type:** ERC-1155 listings automatically use `PARTIAL_RESTRICTED` order type, enabling partial fulfillment. Buyers can purchase any quantity up to the listed amount.
### Approval, Signing, and Submission
The approval, signing, and submission steps are identical to ERC-721:
```typescript theme={null}
// 1. Handle approval (if needed)
for (const action of listing.actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
const txHash = await signer.sendTransaction({
to: action.to,
data: action.data,
});
await txHash.wait();
}
}
// 2. Sign typed data
const `signableAction` = listing.actions.find(
(action) => action.type === orderbook.ActionType.SIGNABLE
);
const signature = await signer._signTypedData(
`signableAction`.message.domain,
`signableAction`.message.types,
`signableAction`.message.value
);
// 3. Submit listing
const { result } = await sdk.createListing({
orderComponents: listing.orderComponents,
orderHash: listing.orderHash,
orderSignature: signature,
makerFees: listing.makerFees,
});
```
## Maker Fees
Maker fees are set at listing creation and **cannot be changed** without canceling and recreating the listing.
```typescript theme={null}
// Single marketplace fee
makerFees: [
{
recipientAddress: '0x...',
amount: '10000000000000000', // 0.01 IMX flat fee
},
]
// Multiple recipients (platform + affiliate)
makerFees: [
{
recipientAddress: platformWallet,
amount: '10000000000000000', // 0.01 IMX to platform
},
{
recipientAddress: affiliateWallet,
amount: '5000000000000000', // 0.005 IMX to affiliate
},
]
```
**Fee Amount Units:** Fees are specified in the smallest currency unit (wei for native tokens).
* 1 IMX = `1000000000000000000` wei (18 decimals)
* Use helper functions to calculate percentage-based fees
## Order Expiration
Set when your listing should automatically expire:
```typescript theme={null}
const listing = await sdk.prepareListing({
// ... other params
orderExpiry: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
```
| Duration | Use Case | Timestamp |
| -------- | ------------------ | ------------------------- |
| 1 hour | Flash sales | `Date.now() + 3600000` |
| 24 hours | Daily deals | `Date.now() + 86400000` |
| 7 days | Standard listings | `Date.now() + 604800000` |
| 30 days | Long-term listings | `Date.now() + 2592000000` |
## Complete Example
Full working example combining all steps:
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
import { Environment } from '@imtbl/config';
import { ethers } from 'ethers';
async function createNFTListing() {
// Initialize SDK
// Get user's wallet
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const `userAddress` = await signer.getAddress();
try {
// 1. Prepare listing
const listing = await sdk.prepareListing({
makerAddress: `userAddress`,
sell: {
type: 'ERC721',
contractAddress: '0x1234...', // Your NFT contract
tokenId: '123',
},
buy: {
type: 'NATIVE',
amount: '1000000000000000000', // 1 IMX
},
makerFees: [{
`recipientAddress`: '0x5678...', // Marketplace fee wallet
amount: '10000000000000000', // 0.01 IMX
}],
`orderExpiry`: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
// 2. Handle approval if needed
for (const action of listing.actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
console.log('Requesting approval...');
const tx = await signer.sendTransaction({
to: action.to,
data: action.data,
});
await tx.wait();
console.log('Approved!');
}
}
// 3. Sign the order
const signableAction = listing.actions.find(
(action) => action.type === orderbook.ActionType.SIGNABLE
);
const signature = await signer._signTypedData(
signableAction.message.domain,
signableAction.message.types,
signableAction.message.value
);
// 4. Submit listing
const { result } = await sdk.createListing({
orderComponents: listing.orderComponents,
orderHash: listing.orderHash,
orderSignature: signature,
makerFees: listing.makerFees,
});
console.log('Listing created!', {
id: result.id,
status: result.status.name,
});
return result;
} catch (error) {
console.error('Failed to create listing:', error);
throw error;
}
}
```
## Error Handling
Common errors and solutions:
| Error | Cause | Solution |
| ---------------------- | ---------------------------------- | -------------------------------------------- |
| `INSUFFICIENT_BALANCE` | User doesn't own the NFT | Verify token ownership before listing |
| `INVALID_APPROVAL` | Approval transaction failed | Retry approval transaction |
| `INVALID_SIGNATURE` | Signature doesn't match order | Ensure signing the correct typed data |
| `INVALID_BUY_AMOUNT` | ERC-1155 buy amount not a multiple | Fix buy amount to be multiple of sell amount |
## Next Steps
Query and manage your listings
Learn how buyers purchase your listings
Understand soft and hard cancellation
Create multiple listings in one transaction
# Fees
Source: https://docs.immutable.com/docs/products/orderbook/fees
The Orderbook supports multiple fee types—protocol fees, royalties, and marketplace fees—that are automatically distributed when trades execute.
## Fee Types
| Fee | Recipient | Set By |
| ---------------- | ------------------- | ------------------- |
| **Protocol Fee** | Immutable | Fixed by protocol |
| **Royalty Fee** | NFT Creator | Set on NFT contract |
| **Maker Fee** | Listing marketplace | Order creator |
| **Taker Fee** | Filling marketplace | Order filler |
## How Fees Work
When a trade executes, fees are deducted from the payment. **All fees are paid by the buyer**, and the seller receives the sale price minus the deducted fees.
```mermaid theme={null}
graph TB
A[1.00 IMX Payment] --> B[Protocol: 0.02]
A --> C[Royalty: 0.05]
A --> D[Maker Fee: 0.01]
A --> E[Taker Fee: 0.01]
A --> F[Seller: 0.91]
```
**Fee Units:** Fees are specified in the **smallest unit** of the currency (wei for native tokens, smallest decimal unit for ERC-20s). For example:
* 1 IMX = 1,000,000,000,000,000,000 wei (18 decimals)
* Fee of "10000000000000000" = 0.01 IMX
Fees are **notional amounts**, not percentages. If an order is partially filled, fees are automatically **pro-rated** by the orderbook.
## Protocol Fee
The protocol fee is a small percentage that supports the Immutable ecosystem.
| Network | Protocol Fee |
| ------- | ------------ |
| Mainnet | 2% |
| Testnet | 2% |
The protocol fee is non-negotiable and applies to all trades on the Orderbook.
## Royalties
Royalties ensure creators earn from secondary sales. They're set on the NFT contract and enforced by the Orderbook.
### Setting Royalties
Royalties are configured when deploying your NFT contract. For complete setup instructions, see [Deploying Contracts with Royalties](/docs/products/asset-contracts/deploy-contracts-with-hardhat) and [Royalty Configuration](/docs/products/asset-contracts/royalties).
```solidity theme={null}
// In your ERC-721 contract
function royaltyInfo(uint256 tokenId, uint256 salePrice)
external
view
returns (address receiver, uint256 royaltyAmount)
{
// 5% royalty to the creator
return (creatorAddress, (salePrice * 500) / 10000);
}
```
### Typical Royalty Rates
| Game Type | Typical Royalty |
| ------------ | --------------- |
| Gaming items | 2-5% |
| Collectibles | 5-10% |
| Art | 5-15% |
**Royalty Enforcement:**
* Royalties must be implemented via the [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist) to be enforced
* The orderbook automatically queries royalty information using the ERC-2981 interface when creating orders
* Royalty amounts are re-validated during order fulfillment
* **zkEVM limitation:** Royalties can only be paid to a **single wallet address**. If you need to split royalties among multiple wallets, contact your Immutable account manager about fee splitter contracts.
## Marketplace Fees
Marketplaces can add their own fees on top of protocol fees and royalties.
### Maker Fees
Set by the marketplace where the order is created:
```typescript theme={null}
const { result } = await orderbookClient.createListing({
orderComponents: prepareListing.orderComponents,
orderHash: prepareListing.orderHash,
orderSignature: signature,
makerFees: [
{
recipientAddress: MARKETPLACE_FEE_WALLET,
amount: '10000000000000000', // 0.01 IMX flat fee
},
],
});
```
### Taker Fees
Set by the marketplace where the order is filled:
```typescript theme={null}
const { actions } = await orderbookClient.fulfillOrder(
orderId,
buyerAddress,
[
{
recipientAddress: MARKETPLACE_FEE_WALLET,
amount: '10000000000000000', // 0.01 IMX flat fee
},
]
);
```
### Maker vs Taker Fee Flexibility
| Fee Type | When Set | Can Change? |
| ------------- | -------------------- | ------------------------------------------------------------- |
| **Maker Fee** | At listing creation | ❌ **Immutable** - To change, must cancel and recreate listing |
| **Taker Fee** | At order fulfillment | ✅ **Flexible** - Can be different for each fulfillment |
**Maker** = Liquidity provider (creates order, adds to orderbook)
**Taker** = Liquidity consumer (fills order, removes from orderbook)
Marketplaces typically charge makers lower fees to incentivize listing creation.
### ERC-1155 Partial Fill Fee Rules
For ERC-1155 tokens that support partial fills, **taker fees must reflect the full order amount**, not the partial fill amount:
```typescript theme={null}
// ❌ WRONG: Scaling fee for partial fill
const listing = { sell: { amount: '10' }, buy: { amount: '1000000000000000000' } }; // 10 items at 0.1 IMX each
const amountToFill = '5'; // Buying 5 of 10
const takerFee = '5000000000000000'; // 0.005 IMX (scaled) - INCORRECT
// ✅ CORRECT: Fee for full order amount
const takerFee = '10000000000000000'; // 0.01 IMX (1% of full order) - CORRECT
// The orderbook will automatically pro-rate this to 0.005 IMX for the partial fill
```
For ERC-1155 orders, **always provide taker fees for the complete order**, even when partially filling. The orderbook automatically pro-rates the fee based on the quantity executed. Setting a scaled-down fee will result in incorrect marketplace compensation.
### Fee Validation and Expiration
When fulfilling an order, the orderbook provides up-to-date fee information and validates all fees server-side:
```typescript theme={null}
const { actions, order, expiration } = await orderbookClient.fulfillOrder(
orderId,
buyerAddress,
[] // taker fees array
);
console.log({
order: order, // Order with CURRENT fees (may differ from listing query)
expiration: expiration // Transaction must be submitted within 3 minutes
});
// User must complete transaction before expiration
// After 3 minutes, request new fulfillment data
```
**Important Fee Timing Rules:**
1. **Fee changes:** Fees at fulfillment time may differ from when the order was queried (e.g., promotional periods with reduced protocol fees)
2. **3-minute expiration:** Transaction data expires 3 minutes after generation. After expiration, request new fulfillment data with updated fees.
3. **Display to users:** Always show users the final fee breakdown from the `fulfillOrder` response before they sign, not from the listing query.
### Percentage-Based Fees
Calculate fees as a percentage of the order value:
```typescript theme={null}
function calculateFee(orderAmount: string, percentageBps: number): string {
// BPS = basis points (100 bps = 1%)
const amount = BigInt(orderAmount);
const fee = (amount * BigInt(percentageBps)) / BigInt(10000);
return fee.toString();
}
// 2.5% marketplace fee
const fee = calculateFee(listing.buy.amount, 250);
const { actions } = await orderbookClient.fulfillOrder(
orderId,
buyerAddress,
[{ recipientAddress: MARKETPLACE_WALLET, amount: fee }]
);
```
## Fee Splitting
Distribute fees to multiple recipients:
```typescript theme={null}
const makerFees = [
{
recipientAddress: PLATFORM_WALLET,
amount: calculateFee(price, 100), // 1% to platform
},
{
recipientAddress: AFFILIATE_WALLET,
amount: calculateFee(price, 50), // 0.5% to affiliate
},
];
await orderbookClient.createListing({
// ...
makerFees,
});
```
## Displaying Fees to Users
Show users the fee breakdown before they trade:
```typescript theme={null}
interface FeeBreakdown {
protocolFee: bigint;
royalty: bigint;
makerFee: bigint;
takerFee: bigint;
sellerReceives: bigint;
}
function calculateFeeBreakdown(
salePrice: bigint,
royaltyBps: number,
makerFeeBps: number,
takerFeeBps: number
): FeeBreakdown {
const protocolFee = (salePrice * 200n) / 10000n; // 2%
const royalty = (salePrice * BigInt(royaltyBps)) / 10000n;
const makerFee = (salePrice * BigInt(makerFeeBps)) / 10000n;
const takerFee = (salePrice * BigInt(takerFeeBps)) / 10000n;
const sellerReceives = salePrice - protocolFee - royalty - makerFee;
return { protocolFee, royalty, makerFee, takerFee, sellerReceives };
}
```
### Example UI
```
You're selling: Sword of Power #123
Price: 1.00 IMX
Fee Breakdown:
Protocol Fee (2%): -0.02 IMX
Creator Royalty (5%): -0.05 IMX
Marketplace Fee (1%): -0.01 IMX
─────────────────────────────────
You'll receive: 0.92 IMX
```
## Fee Limits
| Fee Type | Maximum |
| ---------- | --------------------------------- |
| Royalty | 10% recommended, contract-defined |
| Maker Fee | No protocol limit |
| Taker Fee | No protocol limit |
| Total Fees | Should not exceed sale price |
Keep total fees reasonable (under 15%) to maintain a healthy marketplace. High fees discourage trading.
## Next Steps
Set maker fees when creating listings
Add taker fees when buying NFTs
Calculate fees for shopping carts
Configure royalties on your contracts
# Fill Orders
Source: https://docs.immutable.com/docs/products/orderbook/fill-orders
Complete guide to fulfilling NFT orders (buying) using the Immutable Orderbook SDK
Learn how to fulfill (buy) NFT listings on the Immutable Orderbook. This guide covers filling both ERC-721 and ERC-1155 orders, including partial fills and fee handling.
See [Getting Started](/docs/products/orderbook/overview#getting-started) for prerequisites and installation.
## Quick Overview
Fulfilling an order involves these steps:
1. **Call fulfillOrder()** - SDK prepares transaction actions and validates fees
2. **Execute approval** (if needed) - One-time ERC-20 approval for currency
3. **Execute fulfillment** - Submit the buy transaction
4. **Wait for confirmation** - Order status transitions to `FILLED`
## Understanding Actions
The `fulfillOrder()` call returns **actions** that must be executed in order:
| Action Type | Purpose | When Needed |
| --------------- | -------------------------------------- | --------------------------------------------------------- |
| `APPROVAL` | Approve Seaport to spend ERC-20 tokens | First time using that currency, or insufficient allowance |
| `FULFILL_ORDER` | Execute the trade | Always |
```typescript theme={null}
const { actions, order, expiration } = await sdk.fulfillOrder(
orderId,
`takerAddress`,
`takerFees` // optional
);
console.log({
actions: actions, // Actions to execute
order: order, // Order with CURRENT fees (may differ from query)
expiration: expiration, // Transaction expires in 3 minutes
});
```
**3-Minute Expiration:** Transaction data expires **3 minutes** after generation. If the user doesn't submit within this window, you must call `fulfillOrder()` again to get fresh data with re-validated fees.
**Fee Validation:** The `order` object returned contains the **most current fees**, which may differ from when you queried the listing (e.g., protocol fee reductions during promotions). Always display the fees from this response to users before they sign.
## Filling an ERC-721 Order
ERC-721 orders are all-or-nothing—you must purchase the entire NFT.
### Basic Example
```typescript theme={null}
import { orderbook } from '@imtbl/sdk';
import { ethers } from 'ethers';
async function buyNFT(orderId: string) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const buyerAddress = await signer.getAddress();
// 1. Prepare fulfillment
const { actions, order, expiration } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[] // no taker fees for this example
);
console.log('Order expires:', new Date(expiration.toString()));
console.log('Total cost:', order.buy.amount); // Cost in wei
// 2. Execute all actions
for (const action of actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
// Build and send transaction
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
console.log(`Transaction sent: ${txResponse.hash}`);
// Wait for confirmation
const receipt = await txResponse.wait();
console.log(`Confirmed in block ${receipt.blockNumber}`);
}
}
console.log('NFT purchased successfully!');
}
```
### With Taker Fees
Marketplaces can charge taker fees when facilitating purchases:
```typescript theme={null}
const { actions } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[
{
recipientAddress: '0x...', // Marketplace wallet
amount: '10000000000000000', // 0.01 IMX flat fee
},
]
);
```
**Taker Fee Flexibility:** Taker fees can be different for each fulfillment. Unlike maker fees (set at listing creation), marketplaces can set custom taker fees per transaction.
## Filling collection bids, trait bids, and metadata bids
**Collection bids**, **trait bids**, and **metadata bids** are criteria-style buy orders: the seller (taker) must tell the orderbook **which `tokenId`** they are selling into the bid. In the Orderbook SDK, pass **`tokenId` as the fifth argument** to `fulfillOrder` (after `orderId`, `takerAddress`, `takerFees`, and `amountToFill`). For a typical ERC-721 fill, set **`amountToFill`** to `undefined` and supply **`tokenId`** as a string.
```typescript theme={null}
const { actions } = await sdk.fulfillOrder(
criteriaOrderId,
sellerAddress,
[], // taker fees
undefined, // amountToFill — omit for standard ERC-721 criteria fills
'123', // tokenId — required for collection / trait bids
);
```
* **Collection bid:** any token from the collection can be used (subject to order rules). See [Collection bids](/docs/products/orderbook/collection-bids#accepting-collection-bids-sellers).
* **Trait bid:** the same **`tokenId`** requirement applies, and the token’s **indexed metadata must match** every trait filter on the bid. If metadata is missing or does not match, fulfillment will fail—treat as a validation error and choose another asset or refresh metadata. See [Trait bids](/docs/products/orderbook/trait-bids#filling-a-trait-bid-seller).
* **Metadata bid:** the token’s **`metadata_id`** in the indexer must match the bid’s `metadataId`. See [Metadata bids](/docs/products/orderbook/metadata-bids#filling-a-metadata-bid-seller).
## Filling an ERC-1155 Order (Partial Fills)
ERC-1155 orders support **partial fills**—buy any quantity up to the available amount.
### Full Fill
Buy all available items:
```typescript theme={null}
const { actions } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[] // no taker fees
// amountToFill not specified = full fill
);
```
### Partial Fill
Buy only some of the available items:
```typescript theme={null}
// Listing has 10 items available, buy only 3
const { actions } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[], // taker fees
{
amountToFill: '3', // Buy 3 of 10 items
}
);
```
### Best-Effort Fulfillment
If you request more items than available, the orderbook attempts a "best-effort" fill:
```typescript theme={null}
// Listing has 5 items available, but request 10
const { actions } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[],
{
amountToFill: '10', // Request 10 items
// Will fill only 5 (max available)
}
);
```
**Best Effort:** If `amountToFill` exceeds available quantity, the orderbook fills up to the maximum available. No error is thrown—you get what's available.
## ERC-1155 Taker Fee Rules
For ERC-1155 orders, taker fees have special rules:
**Critical: Taker Fee for Full Order**
The taker fee `amount` must always reflect the **COMPLETE** order, even for partial fills. The orderbook automatically pro-rates the fee based on quantity executed.
**Example:**
```typescript theme={null}
// Listing: 10 items at 1 IMX each = 10 IMX total
// Marketplace wants 1% fee
// ❌ WRONG for partial fill (3 items):
takerFees: [{
amount: '30000000000000000', // 0.03 IMX (scaled for 3 items) - INCORRECT
}]
// CORRECT for any fill (3, 5, or 10 items):
takerFees: [{
amount: '100000000000000000', // 0.1 IMX (1% of 10 IMX total) - CORRECT
}]
// Orderbook will charge: 0.03 IMX for 3 items, 0.05 IMX for 5 items, etc.
```
### Complete ERC-1155 Example
```typescript theme={null}
async function buyPartialERC1155(orderId: string, quantityToBuy: string) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const buyerAddress = await signer.getAddress();
// Get order details first to calculate fee
const { result: order } = await sdk.getListing(orderId);
// Calculate 1% marketplace fee on FULL order value
const fullOrderValue = BigInt(order.buy.amount);
const marketplaceFee = (fullOrderValue * 100n) / 10000n; // 1% in basis points
// Prepare fulfillment
const { actions, order: validatedOrder, expiration } = await sdk.fulfillOrder(
orderId,
buyerAddress,
[
{
recipientAddress: MARKETPLACE_WALLET,
amount: marketplaceFee.toString(), // Full order fee
},
],
{
amountToFill: quantityToBuy, // Partial quantity
}
);
// Show user what they're buying
const itemsAvailable = order.sell.amount;
const itemsToBuy = Math.min(parseInt(quantityToBuy), parseInt(itemsAvailable));
const pricePerItem = fullOrderValue / BigInt(itemsAvailable);
const totalCost = pricePerItem * BigInt(itemsToBuy);
console.log({
itemsToBuy: itemsToBuy,
pricePerItem: ethers.utils.formatEther(pricePerItem),
totalCost: ethers.utils.formatEther(totalCost),
expiresAt: new Date(expiration.toString()),
});
// Execute actions
for (const action of actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
await txResponse.wait();
}
}
console.log(`Purchased ${itemsToBuy} items!`);
}
```
## Approval Handling
For ERC-20 currency listings, buyers must approve Seaport to spend tokens:
```typescript theme={null}
const { actions } = await sdk.fulfillOrder(orderId, buyerAddress, []);
for (const action of actions) {
if (action.type === orderbook.ActionType.TRANSACTION) {
if (action.purpose === orderbook.TransactionPurpose.APPROVAL) {
console.log('Requesting ERC-20 approval...');
// User approves Seaport to spend their ERC-20 tokens
} else if (action.purpose === orderbook.TransactionPurpose.FULFILL_ORDER) {
console.log('Executing purchase...');
// The actual buy transaction
}
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
await txResponse.wait();
}
}
```
**One-Time Approval:** Users only need to approve each ERC-20 currency once (or when allowance is insufficient). Subsequent purchases with the same currency skip the approval step.
**Native Currency:** Listings priced in NATIVE (IMX) never require approval—only a fulfillment transaction.
For architectural context, see the [Approval Pattern](/docs/products/orderbook/overview#approval-pattern) documentation.
## Fill Status
After fulfillment, check the fill status to see how much has been filled:
```typescript theme={null}
const { result: order } = await sdk.getListing(orderId);
const fillStatus = order.fill_status;
console.log({
status: fillStatus.name, // UNFILLED, PARTIAL, or FILLED
numerator: fillStatus.numerator, // Amount filled
denominator: fillStatus.denominator, // Total amount
percentage: (parseInt(fillStatus.numerator) / parseInt(fillStatus.denominator)) * 100,
});
// Check if completely filled
if (fillStatus.numerator === fillStatus.denominator) {
console.log('Order completely filled!');
} else {
// Calculate remaining quantity (useful for ERC-1155)
const remaining = BigInt(fillStatus.denominator) - BigInt(fillStatus.numerator);
console.log(`Order partially filled, ${remaining} items still available`);
}
```
| Fill Status | ERC-721 | ERC-1155 Example |
| ---------------- | ------- | --------------------- |
| Unfilled | `0/0` | `0/10` |
| Partially filled | N/A | `3/10` (30% filled) |
| Fully filled | `1/1` | `10/10` (100% filled) |
**Status Transitions:** Orders transition from `ACTIVE` → `FILLED` asynchronously after the transaction confirms. For partial ERC-1155 fills, status remains `ACTIVE` until completely filled.
## Complete Purchase Flow
Full example with error handling and user feedback:
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
import { Environment } from '@imtbl/config';
import { ethers } from 'ethers';
async function completePurchaseFlow(orderId: string, quantity?: string) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const buyerAddress = await signer.getAddress();
try {
// 1. Get order details
const { result: order } = await sdk.getListing(orderId);
console.log('Order Details:', {
seller: order.account_address,
nft: `${order.sell.contract_address}:${order.sell.token_id}`,
price: ethers.utils.formatEther(order.buy.amount),
currency: order.buy.type,
available: order.sell.amount || '1',
});
// 2. Calculate marketplace fee (1%)
const marketplaceFee = {
recipientAddress: MARKETPLACE_FEE_WALLET,
amount: (BigInt(order.buy.amount) * 100n / 10000n).toString(),
};
// 3. Prepare fulfillment
const fulfillmentParams: any = {
orderId,
takerAddress: buyerAddress,
takerFees: [marketplaceFee],
};
if (quantity && order.sell.type === 'ERC1155') {
fulfillmentParams.amountToFill = quantity;
}
const { actions, order: validatedOrder, expiration } =
await sdk.fulfillOrder(...Object.values(fulfillmentParams));
// 4. Warn user about expiration
const expirationDate = new Date(expiration.toString());
const timeLeft = expirationDate.getTime() - Date.now();
if (timeLeft < 60000) {
console.warn('⚠️ Transaction expires in less than 1 minute!');
}
// 5. Display final costs to user
console.log('Final Costs:', {
itemCost: ethers.utils.formatEther(validatedOrder.buy.amount),
protocolFee: ethers.utils.formatEther(
validatedOrder.fees.find(f => f.type === 'PROTOCOL')?.amount || '0'
),
royalty: ethers.utils.formatEther(
validatedOrder.fees.find(f => f.type === 'ROYALTY')?.amount || '0'
),
marketplaceFee: ethers.utils.formatEther(marketplaceFee.amount),
});
// 6. Execute all actions
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
if (action.type === orderbook.ActionType.TRANSACTION) {
console.log(`Step ${i + 1}/${actions.length}:`, action.purpose);
const unsignedTx = await action.buildTransaction();
const txResponse = await signer.sendTransaction(unsignedTx);
console.log(`Transaction sent: ${txResponse.hash}`);
const receipt = await txResponse.wait();
console.log(`Confirmed in block ${receipt.blockNumber}`);
}
}
// 7. Poll for status update
console.log('Waiting for order status to update...');
let attempts = 0;
while (attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s
const { result: updatedOrder } = await sdk.getListing(orderId);
if (updatedOrder.status.name === 'FILLED' ||
updatedOrder.fill_status.numerator !== '0') {
console.log('Purchase confirmed!', {
filled: `${updatedOrder.fill_status.numerator}/${updatedOrder.fill_status.denominator}`,
status: updatedOrder.status.name,
});
break;
}
attempts++;
}
return { success: true, orderId };
} catch (error: any) {
console.error('Purchase failed:', error.message);
// Handle common errors
if (error.message.includes('insufficient funds')) {
console.error('❌ Buyer has insufficient balance');
} else if (error.message.includes('FILLED')) {
console.error('❌ Order already filled');
} else if (error.message.includes('expired')) {
console.error('❌ Transaction expired, please retry');
}
throw error;
}
}
```
## Error Handling
Common errors during fulfillment:
| Error | Cause | Solution |
| ------------------------ | -------------------------- | -------------------------------- |
| `INSUFFICIENT_FUNDS` | Buyer lacks currency | Show balance, request funding |
| `ORDER_FILLED` | Order already purchased | Refresh listing, show "Sold Out" |
| `ORDER_EXPIRED` | Listing expired | Remove from UI |
| `TRANSACTION_EXPIRED` | Took longer than 3 minutes | Call `fulfillOrder()` again |
| `APPROVAL_FAILED` | User rejected approval | Retry approval step |
| `INSUFFICIENT_ALLOWANCE` | Approval amount too low | Request higher allowance |
## Checking Token Balance
Before fulfilling orders, validate that buyers have sufficient funds:
Use ethers.js to check balance:
```typescript theme={null}
// Check native currency (IMX) balance
const provider = new ethers.providers.Web3Provider(window.ethereum);
const balance = await provider.getBalance(buyerAddress);
console.log('Balance:', ethers.utils.formatEther(balance), 'IMX');
// Check ERC-20 token balance
const tokenContract = new ethers.Contract(
ERC20_ADDRESS,
['function balanceOf(address) view returns (uint256)'],
provider
);
const tokenBalance = await tokenContract.balanceOf(buyerAddress);
```
## Best Practices
1. **Show Expiration Timer:** Display 3-minute countdown to user
2. **Validate Balance:** Check buyer has sufficient funds before calling `fulfillOrder()` (see above)
3. **Display All Fees:** Show protocol fee, royalty, maker fee, taker fee separately
4. **Handle Partial Fills:** For ERC-1155, show available quantity and let users choose amount
5. **Optimistic UI:** Show "Purchase Pending" immediately, update to "Purchased" after confirmation
6. **Refresh Listings:** Poll or use webhooks to detect when orders are filled by others
## Next Steps
Learn how to create NFT listings
Query order status after purchase
Fill multiple orders in one transaction (shopping cart)
Cancel listings with soft or hard cancels
View complete Next.js example on GitHub
# Metadata bids
Source: https://docs.immutable.com/docs/products/orderbook/metadata-bids
Place bids on NFTs in a collection that have a specific **metadata ID**. A metadata bid targets a group of tokens linked by the same metadata definition in Immutable's indexer.
See [Getting Started](/docs/products/orderbook/overview#getting-started) for prerequisites and installation.
## What is a metadata bid?
* You offer **ERC-20** tokens to buy **one or more** NFTs from a collection, identified by an **ERC-721 collection** buy item.
* You attach a **`metadataId`**: a UUID that identifies a group of tokens sharing the same metadata definition in Immutable's [indexer](/docs/products/indexer/overview).
* **Matching semantics:** when a seller fills your bid with a specific `tokenId`, Immutable validates that the token's `metadata_id` in the indexer matches the `metadataId` on the bid.
* Orders appear as **`METADATA_BID`** in API responses alongside listings, bids, collection bids, and trait bids. Use the order `type` field when branching in your app or webhooks.
If you want to filter by **individual trait attributes** (e.g. `Background` is `Blue`) rather than a metadata ID, use a **[trait bid](/docs/products/orderbook/trait-bids)** instead.
## Prerequisites
* Same setup as other orderbook flows ([Passport](/docs/products/passport/authentication) or wallet, [fees](/docs/products/orderbook/fees), etc.).
* **Metadata for fulfillment:** when a seller fills your bid with a specific `tokenId`, Immutable validates that token's **`metadata_id`** against the bid's `metadataId`. That requires the NFT metadata to be available through Immutable's [indexer](/docs/products/indexer/overview). If metadata is missing or the `metadata_id` does not match, fulfillment will fail.
## Creating a metadata bid
The flow mirrors [collection bids](/docs/products/orderbook/collection-bids): **prepare** (builds `orderComponents`, `orderHash`, and `actions`) → run any **approval** transactions → **sign** the EIP-712 order from the `SIGNABLE` action → **create**.
```typescript theme={null}
import { Orderbook, ActionType } from '@imtbl/orderbook';
const prepared = await orderbook.prepareMetadataBid({
makerAddress: address,
buy: {
type: 'ERC721_COLLECTION',
contractAddress: NFT_CONTRACT,
amount: '1',
},
sell: {
type: 'ERC20',
contractAddress: PAYMENT_TOKEN,
amount: '1000000000000000000', // wei
},
});
// 1) Submit approval txs from prepared.actions (if any)
for (const action of prepared.actions) {
if (action.type === ActionType.TRANSACTION) {
const tx = await action.buildTransaction();
await walletClient.sendTransaction(tx);
}
}
// 2) Sign the CREATE_ORDER payload from the SIGNABLE action (EIP-712)
const signable = prepared.actions.find((a) => a.type === ActionType.SIGNABLE);
if (!signable) throw new Error('Missing signable order action');
const orderSignature = await walletClient.signTypedData({
account: address,
domain: signable.message.domain,
types: signable.message.types,
primaryType: 'OrderComponents',
message: signable.message.value,
});
// 3) Create the metadata bid on Immutable
const { result } = await orderbook.createMetadataBid({
orderComponents: prepared.orderComponents,
orderHash: prepared.orderHash,
orderSignature,
makerFees: [],
metadataId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', // UUID from the indexer
});
console.log('Metadata bid created:', result.id);
```
## Filling a metadata bid (seller)
Sellers call **`fulfillOrder`** with the **metadata bid order id** and the **token ID** they are selling into the bid. Pass **`undefined`** for `amountToFill` (standard ERC-721 fill) and supply **`tokenId`** as a string.
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
const { actions } = await orderbook.fulfillOrder(
metadataBidId,
sellerAddress,
[], // taker fees
undefined, // amountToFill — omit for standard ERC-721 fills
'123', // tokenId — required: the NFT the seller is selling into the bid
);
// Execute actions (approvals + fulfill) in order
for (const action of actions) {
if (action.type === 'TRANSACTION') {
const unsignedTx = await action.buildTransaction();
const hash = await walletClient.sendTransaction(unsignedTx);
await publicClient.waitForTransactionReceipt({ hash });
}
}
```
If the token's **`metadata_id`** in the indexer does not match the bid's `metadataId`, the orderbook will **not** return valid fulfillment data for that token.
For more context on actions, fees, and expiry, see [Fill orders](/docs/products/orderbook/fill-orders).
## Querying metadata bids
### List metadata bids
Filter by collection contract, status, maker, and pagination.
```typescript theme={null}
import { Orderbook, OrderStatusName } from '@imtbl/orderbook';
const { result, page } = await orderbook.listMetadataBids({
buyItemContractAddress: NFT_CONTRACT,
status: OrderStatusName.ACTIVE,
pageSize: 50,
});
for (const bid of result) {
console.log(bid.id, bid.metadataId, bid.sell.amount);
}
```
### Get one metadata bid
```typescript theme={null}
const { result: bid } = await orderbook.getMetadataBid(metadataBidId);
console.log({
id: bid.id,
status: bid.status,
metadataId: bid.metadataId,
sell: bid.sell,
buy: bid.buy,
});
```
## Comparison: collection bid vs trait bid vs metadata bid
| Aspect | Collection bid | Trait bid | Metadata bid |
| -------------------------- | --------------------------- | ------------------------------------------------ | ------------------------------------------------------ |
| **Buy target** | Any token in the collection | Any token whose metadata matches `traitCriteria` | Any token whose `metadata_id` matches `metadataId` |
| **Filter mechanism** | None | Array of trait type/value filters | Single metadata ID (UUID) |
| **Fulfillment validation** | Token exists in collection | Token attributes satisfy all trait filters | Token `metadata_id` matches bid |
| **Typical use** | Floor sweep / any item | Offers on filtered sets (e.g. legendary + blue) | Offers on tokens from a specific template or blueprint |
## Fees and cancellation
* **Fees:** same maker/taker concepts as other orders — see [Fees](/docs/products/orderbook/fees).
* **Cancel:** use the bid's order id with [Cancel orders](/docs/products/orderbook/cancel-orders) (soft or hard cancel patterns apply to open orders).
## Next steps
Bids on any NFT in a collection without filters
Bids filtered by metadata trait attributes
Fulfillment, actions, and approvals
Cancel metadata bids you created
# Order Management
Source: https://docs.immutable.com/docs/products/orderbook/order-management
## Querying Orders
### Get a Single Order
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
const { result: order } = await orderbook.getListing(orderId);
console.log({
id: order.id,
status: order.status,
price: order.buy.amount,
seller: order.accountAddress,
tokenId: order.sell.tokenId,
});
```
### List Orders for a Collection
```typescript theme={null}
import { Orderbook, OrderStatusName } from '@imtbl/orderbook';
const { result } = await orderbook.listListings({
sellItemContractAddress: NFT_CONTRACT,
status: OrderStatusName.ACTIVE,
pageSize: 50,
});
for (const listing of result) {
console.log(`Token #${listing.sell.tokenId}: ${listing.buy.amount} IMX`);
}
```
### Pagination
```typescript theme={null}
let cursor: string | undefined;
do {
const { result, page } = await orderbook.listListings({
sellItemContractAddress: NFT_CONTRACT,
status: OrderStatusName.ACTIVE,
pageSize: 100,
pageCursor: cursor,
});
for (const listing of result) {
console.log(listing.id);
}
cursor = page.nextCursor;
} while (cursor);
```
## Order Status
For complete order status definitions and lifecycle, see [Order Lifecycle](/docs/products/orderbook/overview#order-lifecycle).
## Fill Status
For fill status details and tracking partial fills, see [Fill Orders: Fill Status](/docs/products/orderbook/fill-orders#fill-status).
## Cancelling Orders
For detailed cancellation methods, race conditions, and best practices, see [Cancel Orders](/docs/products/orderbook/cancel-orders).
## Webhooks
Get real-time notifications for order events. Configure webhooks in [Hub](https://hub.immutable.com). For implementation details, see the [webhook documentation](/docs/products/hub/webhooks).
| Event | Trigger |
| --------------------- | -------------------- |
| `imtbl_order_updated` | Order status changed |
| `imtbl_activity_sale` | Trade completed |
Webhook payloads can represent **listings**, **bids**, **collection bids**, **trait bids**, or **metadata bids**. Use the order **`type`** field (and related discriminator fields your integration already uses) to branch—for example, handle `TRAIT_BID` when you need to read **`trait_criteria`** / **`traitCriteria`** for display or analytics, or handle `METADATA_BID` when you need to read the **`metadata_id`** / **`metadataId`**.
### Webhook Payload Example
```json theme={null}
{
"event_name": "imtbl_order_updated",
"data": {
"id": "order-123",
"status": "FILLED",
"type": "LISTING",
"sell": {
"type": "ERC721",
"contract_address": "0x...",
"token_id": "456"
},
"buy": {
"type": "NATIVE",
"amount": "1000000000000000000"
}
}
}
```
Trait bid payloads include the same high-level `sell` / `buy` / `status` fields and add **trait criteria**
```json theme={null}
{
"event_name": "imtbl_order_updated",
"data": {
"id": "trait-bid-456",
"status": "ACTIVE",
"type": "TRAIT_BID",
"trait_criteria": [
{ "trait_type": "Background", "values": ["Blue", "Red"] }
],
"sell": [{ "type": "ERC20", "contract_address": "0x...", "amount": "1000000000000000000" }],
"buy": [{ "type": "ERC721_COLLECTION", "contract_address": "0xnft...", "amount": "1" }]
}
}
```
Metadata bid payloads include the same high-level `sell` / `buy` / `status` fields and add a **`metadata_id`**
```json theme={null}
{
"event_name": "imtbl_order_updated",
"data": {
"id": "metadata-bid-789",
"status": "ACTIVE",
"type": "METADATA_BID",
"metadata_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"sell": [{ "type": "ERC20", "contract_address": "0x...", "amount": "1000000000000000000" }],
"buy": [{ "type": "ERC721_COLLECTION", "contract_address": "0xnft...", "amount": "1" }]
}
}
```
## Next Steps
Learn how to create NFT listings
Buy NFTs by filling orders
Cancel listings with soft or hard cancels
Understand fee structure and calculations
Create and query metadata-filtered collection bids
Create and query metadata ID-based collection bids
# Orderbook
Source: https://docs.immutable.com/docs/products/orderbook/overview
Immutable's decentralised trading protocol. Orders created on any marketplace are visible and fillable across the entire ecosystem.
## Why Orderbook?
Orders are visible across all Immutable marketplaces. Your players access the entire ecosystem's liquidity, not just your marketplace.
Creating orders is free—sellers only sign a message. Gas is only paid when orders are filled.
Royalties are enforced at the protocol level. Creators always get paid on secondary sales.
Trades settle on-chain immediately. No waiting, no counterparty risk.
## Core Concepts
Before diving into implementation, understand these universal orderbook concepts that apply across all platforms:
### Order Types
| Order Type | Description | Who Creates | Example |
| ------------------------ | ------------------------------------------------------------------------------ | ----------- | --------------------------------------------------------------------------------- |
| **Listing** (Sell Order) | NFT owner offers to sell at fixed price | Seller | "Selling Sword #123 for 1 IMX" |
| **Bid** (Buy Order) | Buyer offers to buy specific NFT | Buyer | "Offering 0.5 IMX for Sword #123" |
| **Collection Bid** | Buyer offers to buy ANY NFT in collection | Buyer | "Offering 0.3 IMX for any Sword" |
| **Trait Bid** | Buyer offers to buy NFTs in a collection that **match metadata trait filters** | Buyer | "Offering 0.4 IMX for any Sword with Background Blue or Red and Rarity Legendary" |
| **Metadata Bid** | Buyer offers to buy NFTs in a collection that share a specific **metadata ID** | Buyer | "Offering 0.4 IMX for any Sword with metadata-id 01234-abcde..." |
### Token Standards
Different NFT types behave differently in the orderbook:
* Each token is unique (e.g., specific Sword #123)
* Amount is always `1`
* Orders use `FULL_RESTRICTED` type
* Cannot be partially filled
* Most common for gaming items and collectibles
* [Learn more about ERC-721 contracts](/docs/products/asset-contracts/erc721)
* Tokens can have multiple copies (e.g., 100 copies of Health Potion)
* Amount can be any quantity
* Orders use `PARTIAL_RESTRICTED` type
* **[Supports partial fills](/docs/products/orderbook/fill-orders#partial-fill)** (e.g., buy 5 of 10 available)
* Great for in-game consumables and stackable items
* [Learn more about ERC-1155 contracts](/docs/products/asset-contracts/erc1155)
* **ERC-20:** Fungible tokens (e.g., USDC, project tokens)
* **NATIVE:** Chain native currency (IMX on Immutable zkEVM)
* Used as payment currency in orders
* Amounts specified in smallest unit (wei)
* [Learn more about ERC-20 tokens](/docs/products/asset-contracts/erc20)
### Maker vs Taker Model
The orderbook uses a maker/taker model to distinguish between liquidity providers and consumers:
```mermaid theme={null}
graph LR
A[Seller Creates] -->|Maker| B[Orderbook]
B --> C[Buyer Fills]
C -->|Taker| D[Trade]
```
**Maker** 👷 Creates an order that goes on the orderbook (adds liquidity)
* Listings: Sellers are makers
* Bids: Buyers are makers
* Collection bids, **trait bids**, and **metadata bids**: Buyers are makers (offers to buy into a collection or filtered subset)
* Typically pay **lower fees** to incentivize order creation
* Maker fees set at order creation and cannot be changed
**Taker** 🛒 Fills an existing order (removes liquidity)
* Filling listings: Buyers are takers
* Filling bids: Sellers are takers
* Filling collection or **trait** bids: Sellers are takers (they choose which `tokenId` to sell into the bid)
* Typically pay **higher fees** as they consume liquidity
* Taker fees set flexibly at fulfillment time
### Order Lifecycle
Orders progress through these statuses:
```mermaid theme={null}
stateDiagram-v2
[*] --> PENDING
PENDING --> ACTIVE
ACTIVE --> FILLED
ACTIVE --> CANCELLED
ACTIVE --> EXPIRED
ACTIVE --> INACTIVE
INACTIVE --> ACTIVE
INACTIVE --> CANCELLED
FILLED --> [*]
CANCELLED --> [*]
EXPIRED --> [*]
```
| Status | Description | Terminal? |
| ----------- | --------------------------------------------------------------- | --------- |
| `PENDING` | Order submitted, awaiting balance/approval checks | No |
| `ACTIVE` | Order is live and can be filled | No |
| `INACTIVE` | Temporarily unfillable (insufficient balance, revoked approval) | No |
| `FILLED` | Order completely filled | Yes |
| `CANCELLED` | Order cancelled by maker | Yes |
| `EXPIRED` | Order passed expiration time | Yes |
**Optimistic UI:** Status transitions happen asynchronously. Build your UI to handle `PENDING → ACTIVE` transitions gracefully.
### Approval Pattern
The orderbook requires token approvals before certain operations:
**For Sellers (Creating Listings):**
* Before creating your first listing for an NFT collection, approve the Seaport contract to transfer your tokens
* One-time per collection per user
* Approval transaction costs gas
* Subsequent listings of that collection don't need approval
* Smart contract wallets ([Passport](/docs/products/passport)) may have [pre-approved contracts](/docs/products/passport/pre-approved-transactions) via [Hub configuration](https://hub.immutable.com)
**For Buyers (Filling ERC-20 Orders):**
* Before buying with ERC-20 tokens, approve Seaport to spend those tokens
* One-time per currency per user (or when allowance insufficient)
* Native (IMX) orders never require buyer approval
For implementation details, see [Creating Listings](/docs/products/orderbook/create-listings#handle-approval-transaction) and [Filling Orders](/docs/products/orderbook/fill-orders#approval-handling)
## Getting Started
### Prerequisites
Before using the Orderbook SDK, ensure you have:
* **User Authentication**: Users authenticated with [Passport](/docs/products/passport/authentication) or wallet connected (MetaMask, WalletConnect, etc.)
* **For Sellers**:
* [NFT contract deployed](/docs/products/asset-contracts/overview) with contract address and token ID
* Understanding of [maker fees](/docs/products/orderbook/fees#maker-fees)
* **For Buyers**:
* [Sufficient funds in wallet](/docs/products/checkout/overview) (IMX or ERC-20 tokens)
* Understanding of [taker fees](/docs/products/orderbook/fees#taker-fees)
* **For Order Management**:
* Order IDs for querying or cancellation
* Order ownership (for cancellation operations)
### Installation
Install the orderbook package via npm:
```bash theme={null}
npm install @imtbl/orderbook @imtbl/config
```
View all available packages and configuration options
### Setup
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
import { Environment } from '@imtbl/config';
const orderbook = new Orderbook({
baseConfig: {
environment: Environment.SANDBOX,
publishableKey: 'YOUR_PUBLISHABLE_KEY',
},
});
```
## Creating Listings
Listings let users sell NFTs at a fixed price. Creating a listing is gasless—only the buyer pays gas when filling.
**Signed Zone v2 Migration**
As of May 2024, all new listings must use **Signed Zone v2** contract. The legacy v1 zone contract is deprecated and will be **sunset in May 2025**.
* **SDK users:** Contract address automatically updated
* **Direct API users:** Update zone contract address manually (not recommended - [see contract addresses](https://github.com/immutable/contracts/blob/main/contract_address.json))
* **Existing listings:** v1 listings remain supported until May 2025 sunset date
### List an ERC-721 NFT
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
// Get the user's wallet address
const address = await walletClient.getAddresses().then(a => a[0]);
// Prepare the listing
const { orderComponents, orderHash, typedData } = await orderbook.prepareListing({
makerAddress: address,
buy: {
type: 'NATIVE',
amount: '1000000000000000000', // 1 IMX in wei
},
sell: {
type: 'ERC721',
contractAddress: NFT_CONTRACT,
tokenId: '123',
},
});
// Sign the order (gasless)
const signature = await walletClient.signTypedData({
account: address,
...typedData,
});
// Submit the listing
const { result } = await orderbook.createListing({
orderComponents,
orderHash,
orderSignature: signature,
makerFees: [],
});
console.log('Listing created:', result.id);
```
### List an ERC-1155 NFT
For semi-fungible tokens, specify the quantity. **Buy amount must be a multiple of sell amount.**
```typescript theme={null}
const { orderComponents, orderHash, typedData } = await orderbook.prepareListing({
makerAddress: address,
buy: {
type: 'NATIVE',
amount: '500000000000000000', // 0.5 IMX per item (5 IMX total for 10 items)
},
sell: {
type: 'ERC1155',
contractAddress: NFT_CONTRACT,
tokenId: '456',
amount: '10', // Selling 10 copies
},
});
// Buy amount (5 IMX) must be multiple of sell amount (10 items)
// This enables partial fills: buyer can purchase 1, 2, 5, or 10 items
```
**Order Type Differences:**
* **ERC-721** uses `FULL_RESTRICTED` order type (cannot be partially filled, amount always 1)
* **ERC-1155** uses `PARTIAL_RESTRICTED` order type (supports partial fills)
* SDK automatically sets the correct order type based on token type
## Filling Listings (Buying)
When a buyer fills a listing, the trade executes on-chain.
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
// Prepare the fill
const { actions } = await orderbook.fulfillOrder(
`listingId`,
address, // buyer address
[] // taker fees (optional)
);
// Execute all required actions (approvals + fill)
for (const action of actions) {
if (action.type === 'TRANSACTION') {
const hash = await walletClient.sendTransaction({
to: action.to,
data: action.data,
value: BigInt(action.value || '0'),
});
await publicClient.waitForTransactionReceipt({ hash });
}
}
console.log('Purchase complete!');
```
## Creating Bids
Bids let users make offers on specific NFTs.
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
// Prepare the bid
const { orderComponents, orderHash, typedData } = await orderbook.prepareBid({
makerAddress: address,
buy: {
type: 'ERC721',
contractAddress: NFT_CONTRACT,
tokenId: '123',
},
sell: {
type: 'NATIVE',
amount: '500000000000000000', // Offering 0.5 IMX
},
});
// Sign and submit (same pattern as listings)
const signature = await walletClient.signTypedData({
account: address,
...typedData,
});
const { result } = await orderbook.createBid({
orderComponents,
orderHash,
orderSignature: signature,
makerFees: [],
});
console.log('Bid created:', result.id);
```
## Order Expiration
Set when orders should expire:
```typescript theme={null}
const prepared = await orderbook.prepareListing({
makerAddress: address,
buy: { type: 'NATIVE', amount: '1000000000000000000' },
sell: { type: 'ERC721', contractAddress: NFT_CONTRACT, tokenId: '123' },
// Expire in 7 days
expiration: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
```
| Expiration | Use Case |
| ---------- | ------------------ |
| 1 hour | Flash sales |
| 24 hours | Daily auctions |
| 7 days | Standard listings |
| 30 days | Long-term listings |
## Next Steps
Sell NFTs with gasless listings
Buy NFTs from the orderbook
Query orders, check status, and cancel
Create multiple listings or fill orders in bulk
Bid on any NFT in a collection
Bid on NFTs that match metadata traits in a collection
Bid on NFTs that share a specific metadata ID
Protocol fees, royalties, and marketplace fees
# Trait bids
Source: https://docs.immutable.com/docs/products/orderbook/trait-bids
Place bids on NFTs in a collection whose metadata matches filters you define (for example, `Background` is `Blue` or `Red`). Trait bids are a stricter form of [collection bids](/docs/products/orderbook/collection-bids).
The seller must still choose a concrete token when filling, but that token’s **indexed attributes** must satisfy all of your trait criteria.
See [Getting Started](/docs/products/orderbook/overview#getting-started) for prerequisites and installation.
## What is a trait bid?
* You offer **ERC-20** tokens to buy **one or more** NFTs from a collection,
identified by an **ERC-721 collection** or **ERC-1155 collection** buy item.
* You attach **`traitCriteria`**: an array of filters. Each filter has a
**trait type** (metadata attribute name) and a list of **allowed values**.
* **Matching semantics:** every trait type you specify must be matched by the NFT’s metadata. For a given type, the token matches if its attribute value is **one** of the values you listed.
Matching is **case-insensitive** for values and numerical values are converted to their string equivalent.
* Orders appear as **`TRAIT_BID`** in API responses alongside listings, bids, and collection bids. Use the order `type` field when branching in your app or webhooks.
## Prerequisites
* Same setup as other orderbook flows ([Passport](/docs/products/passport/authentication) or wallet, [fees](/docs/products/orderbook/fees), etc.).
* **Metadata for fulfillment:** when a seller fills your bid with a specific `tokenId`, Immutable validates that token’s **attributes** against your criteria.
That requires the NFT’s metadata to be available through Immutable’s [indexer](docs/products/indexer/overview). If metadata is missing or out of date, fulfillment can fail even
if the token exists on-chain.
## Creating a trait bid
The flow mirrors [collection bids](/docs/products/orderbook/collection-bids): **prepare** (builds `orderComponents`, `orderHash`, and `actions`) → run any **approval** transactions → **sign** the EIP-712 order from the `SIGNABLE` action → **create**.
```typescript theme={null}
import { Orderbook, ActionType } from '@imtbl/orderbook';
const prepared = await orderbook.prepareTraitBid({
makerAddress: address,
buy: {
type: 'ERC721_COLLECTION',
contractAddress: NFT_CONTRACT,
amount: '1',
},
sell: {
type: 'ERC20',
contractAddress: PAYMENT_TOKEN,
amount: '1000000000000000000', // wei
},
});
// 1) Submit approval txs from prepared.actions (if any)
for (const action of prepared.actions) {
if (action.type === ActionType.TRANSACTION) {
const tx = await action.buildTransaction();
await walletClient.sendTransaction(tx);
}
}
// 2) Sign the CREATE_ORDER payload from the SIGNABLE action (EIP-712; same shape as collection bids)
const signable = prepared.actions.find((a) => a.type === ActionType.SIGNABLE);
if (!signable) throw new Error('Missing signable order action');
const orderSignature = await walletClient.signTypedData({
account: address,
domain: signable.message.domain,
types: signable.message.types,
primaryType: 'OrderComponents',
message: signable.message.value,
});
// 3) Create the trait bid on Immutable
const { result } = await orderbook.createTraitBid({
orderComponents: prepared.orderComponents,
orderHash: prepared.orderHash,
orderSignature,
makerFees: [],
// The NFT used for fulfillment must have:
// - A `Background` trait, with a value of eitehr `Blue` or `Red`
// - A `Rarity` trait, with a value of `Legendary`
traitCriteria: [
{ traitType: 'Background', values: ['Blue', 'Red'] },
{ traitType: 'Rarity', values: ['Legendary'] },
],
});
console.log('Trait bid created:', result.id);
```
## Filling a trait bid (seller)
Sellers call **`fulfillOrder`** with the **trait bid order id** and the **token ID** they are selling. The fifth argument is `tokenId` (criteria fulfillment); pass **`undefined`** for `amountToFill` when you are not doing a partial ERC-1155 quantity fill.
```typescript theme={null}
import { Orderbook } from '@imtbl/orderbook';
const { actions } = await orderbook.fulfillOrder(
traitBidId,
sellerAddress,
[], // taker fees
undefined, // amountToFill — unused for standard ERC-721 trait fill
'123', // tokenId — required: which NFT the seller is selling into the bid
);
// Execute actions (approvals + fulfill) in order — same as filling listings
for (const action of actions) {
if (action.type === 'TRANSACTION') {
const unsignedTx = await action.buildTransaction();
const hash = await walletClient.sendTransaction(unsignedTx);
await publicClient.waitForTransactionReceipt({ hash });
}
}
```
If the token’s **indexed metadata** does not satisfy **every** trait filter on the bid, the orderbook will **not** return valid fulfillment data for that token.
For more context on actions, fees, and expiry, see [Fill orders](/docs/products/orderbook/fill-orders).
## Querying trait bids
### List trait bids
Filter by collection contract, status, maker, and pagination — same ergonomics as `listCollectionBids`.
```typescript theme={null}
import { Orderbook, OrderStatusName } from '@imtbl/orderbook';
const { result, page } = await orderbook.listTraitBids({
buyItemContractAddress: NFT_CONTRACT,
status: OrderStatusName.ACTIVE,
pageSize: 50,
});
for (const bid of result) {
console.log(bid.id, bid.traitCriteria, bid.sell.amount);
}
```
### Get one trait bid
```typescript theme={null}
const { result: bid } = await orderbook.getTraitBid(traitBidId);
console.log({
id: bid.id,
status: bid.status,
traitCriteria: bid.traitCriteria,
sell: bid.sell,
buy: bid.buy,
});
```
## Comparison: token bid vs collection bid vs trait bid vs metadata bid
| Aspect | Token bid | Collection bid | Trait bid | Metadata bid |
| --------------- | ----------------------- | --------------------------- | -------------------------------------------------------------- | ------------------------------------------------------ |
| **Buy target** | One specific `tokenId` | Any token in the collection | Any token whose metadata matches `traitCriteria` | Any token whose `metadata_id` matches `metadataId` |
| **Fulfillment** | Seller sells that token | Seller passes **`tokenId`** | Seller passes **`tokenId`** + metadata must match | Seller passes **`tokenId`** + `metadata_id` must match |
| **Typical use** | Target a rare ID | Floor sweep / any item | Offers on **filtered** sets (e.g. legendary + blue background) | Offers on tokens from a specific template or blueprint |
## Fees and cancellation
* **Fees:** same maker/taker concepts as other orders — see [Fees](/docs/products/orderbook/fees).
* **Cancel:** use the bid’s order id with [Cancel orders](/docs/products/orderbook/cancel-orders) (soft or hard cancel patterns apply to open orders).
## Next steps
Bids on any NFT in a collection without trait filters
Bids on tokens sharing a specific metadata ID
Fulfillment, actions, and approvals
Status, pagination, and webhooks
Cancel trait bids you created
# Architecture
Source: https://docs.immutable.com/docs/products/passport/architecture
How Passport secures user wallets with distributed key management
Passport provides non-custodial wallets without seed phrases. This page explains how it works and compares it to other wallet solutions.
## Smart Contract Wallet
Each Passport user has a smart contract wallet deployed on Immutable Chain. This enables:
Immutable sponsors gas for approved operations. Players never need to buy IMX just to play.
Whitelisted game contracts can execute instantly without confirmation popups—critical for real-time gameplay.
Lost your device? Log in with the same social account to regain access. No seed phrase needed.
Rate limiting, spending limits, and fraud detection protect users from malicious actors.
Wallet addresses are defined at account creation through CREATE-2 counterfactual deployment, with the actual contract deployed when the user performs their first transaction.
### Contract Source Code
Passport's wallet contracts are open source:
View the smart contracts powering Passport wallets
### Audits
| Component | Auditor | Report |
| ---------------- | ------------- | -------------------------------------------------------------------------------- |
| Wallet Contracts | Trail of Bits | [View on GitHub](https://github.com/immutable/wallet-contracts/tree/main/audits) |
## How Passport Works
Passport wallets are controlled by a **2-of-2 multisig**. Every transaction requires signatures from two separate keys:
| Key | Stored By | Purpose |
| ---------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **User Key** | [Magic](https://magic.link) | Downloaded to the user's device to sign transactions. Only the user can initiate transactions. |
| **Guardian Key** | Immutable | Enforces security policies: rate limits, spending caps, fraud detection, and protection against malicious games. |
Immutable never has access to the User Key. All transactions must be signed directly on the user's device—we cannot move funds without user action.
### Key Generation Flow
When a user authenticates with Passport:
1. User logs in via Immutable's OAuth flows (Google, Apple, email, etc.)
2. [Magic's infrastructure](https://magic.link) securely generates and stores the User Key
3. The User Key is downloaded to the user's device for signing transactions
4. Immutable's Guardian Key co-signs to enforce security policies
### Security Guarantees
| Property | Guarantee |
| ------------------------ | ----------------------------------------------------- |
| **Non-custodial** | User controls their private key |
| **No unilateral access** | Immutable cannot move funds without user action |
| **Recoverable** | Lost device? Log in again with same identity provider |
| **No seed phrases** | Key tied to authenticated identity |
## Comparing Wallet Solutions
Passport combines the best aspects of traditional and embedded wallets:
| Feature | MetaMask | Privy / Magic | Passport |
| ------------------------ | ----------------------------- | -------------------- | --------------------------- |
| **Onboarding friction** | High (extension, seed phrase) | Low (social login) | Low (social login) |
| **Transaction friction** | High (manual approval) | Low | Low + pre-approved options |
| **Cross-app identity** | ✅ One wallet everywhere | ❌ New wallet per app | ✅ One wallet everywhere |
| **Cross-device access** | ❌ Manual seed import | ✅ Login to access | ✅ Login to access |
| **User owns keys** | ✅ | ✅ | ✅ |
| **Gaming optimized** | ❌ | ❌ | ✅ Pre-approved transactions |
### The Problem with Traditional Embedded Wallets
Services like Privy and standalone Magic create a new wallet for each application. This fragments users across many addresses:
* **Scattered assets**: NFTs and tokens split across wallets
* **No unified identity**: Can't build cross-game reputation
* **Portfolio confusion**: Users don't know where their assets are
### Passport's Approach
Passport solves this by providing **one wallet that works across all Immutable games and apps**:
* Same address everywhere on Immutable
* Assets visible in any Passport-enabled app
* Build reputation and history across the ecosystem
* Still get the low-friction embedded wallet experience
**Best of both worlds**: Passport delivers the seamless UX of embedded wallets with the unified identity of traditional wallets like MetaMask.
## Supported Chains
| 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 [Support](https://support.immutable.com), but this will not be able to be resolved until Passport supports all EVM chains.
## Next Steps
Implement login flows
Send transactions and sign messages
# Authentication
Source: https://docs.immutable.com/docs/products/passport/authentication
## Prerequisites
### Create Passport Client
Set up your Passport client in Immutable Hub to get the credentials needed for authentication.
Step-by-step guide to creating a Passport client, configuring redirect URIs, and getting your Client ID
You'll need:
* **Client ID** from your Passport client in Hub
* **Publishable Key** from your Hub project settings
These values are required to initialize Passport in the next section.
### Passport Credentials Reference
| Field | Type | Description |
| -------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Client ID** | Provided by Hub | Unique identifier for your application. Copy from Hub and use in SDK initialization. |
| **Publishable Key** | Provided by Hub | Public key safe for client-side code. Copy from Hub project settings. |
| **Application Type** | You configure | Select **Web** for TypeScript/web apps, **Native** for Unity/Unreal games. |
| **Application Name** | You configure | Identifier for your project inside Passport (e.g., "My Game"). |
| **Redirect URIs** | You configure | Where users land after successful authentication. Must exactly match `redirectUri` in your code. Examples: `http://localhost:3000/redirect` (web), `mygame://callback` (native). |
| **Logout URIs** | You configure | Where users land after logout. Must exactly match `logoutRedirectUri` in your code. Examples: `http://localhost:3000/logout` (web), `mygame://logout` (native). |
Passport uses [OpenID Connect](https://openid.net/connect/) (OIDC). Redirect URIs must be exact matches—wildcards aren't supported for security reasons. Register multiple URIs for different environments (localhost, staging, production).
For complete details on client configuration, see [Passport Clients in Hub](/docs/products/hub/passport-clients).
## Installation
Install the Immutable SDK for your platform to get started with Passport:
Install via npm or yarn
Install via npm for App Router
Install via Package Manager
Install Passport plugin in your Unreal project
## Initialize Passport
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
```
For full setup details including server utilities and route protection, see the [Next.js integration guide](/docs/sdks/typescript/overview#nextjs-server-utilities).
```typescript theme={null}
import { Auth, AuthConfiguration } from '@imtbl/auth';
import { Environment } from '@imtbl/config';
export const auth = new Auth(new AuthConfiguration({
environment: Environment.SANDBOX,
clientId: process.env.NEXT_PUBLIC_CLIENT_ID || 'YOUR_CLIENT_ID',
redirectUri: 'http://localhost:3000/redirect',
logoutRedirectUri: 'http://localhost:3000/logout',
logoutMode: 'redirect', // or 'silent' - see Logout section for details
audience: 'platform_api',
scope: 'openid offline_access email transact',
}));
```
### Scopes
| Scope | Required | Description |
| ---------------- | -------- | ---------------------------------------------- |
| `openid` | ✅ Yes | Returns user ID (sub claim) |
| `offline_access` | ✅ Yes | Enables refresh tokens for persistent sessions |
| `email` | Optional | Access to user's email address |
| `transact` | Optional | Permission to sign transactions |
```csharp theme={null}
using Immutable.Passport;
public class GameManager : MonoBehaviour
{
private Passport passport;
async void Start()
{
// Initialize Passport
passport = await Passport.Init(
clientId: "YOUR_CLIENT_ID",
environment: Immutable.Passport.Model.Environment.SANDBOX,
redirectUri: "mygame://callback",
logoutRedirectUri: "mygame://logout"
);
// Restore session if available
if (await passport.HasCredentialsSaved())
{
await passport.Login(useCachedSession: true);
}
}
}
```
```cpp theme={null}
#pragma once
#include "Immutable/ImmutableSubsystem.h"
#include "Immutable/ImmutablePassport.h"
#include "PassportQuickStartSubsystem.generated.h"
UCLASS()
class UPassportQuickStartSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override
{
Super::Initialize(Collection);
auto Subsystem = Collection.InitializeDependency();
Subsystem->WhenReady(this, &UPassportQuickStartSubsystem::OnPassportReady);
}
private:
void OnPassportReady(TWeakObjectPtr JSConnector)
{
auto Passport = GetPassport();
if (!Passport)
{
UE_LOG(LogTemp, Error, TEXT("Failed to get Passport instance"));
return;
}
Passport->Initialize(FImmutablePassportInitData
{
TEXT("YOUR_CLIENT_ID"),
TEXT("mygame://callback"),
TEXT("mygame://logout"),
TEXT("sandbox"),
false
}, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnPassportInitialized));
}
void OnPassportInitialized(FImmutablePassportResult Result)
{
if (!Result.Success)
{
UE_LOG(LogTemp, Error, TEXT("Passport Initialization Failed: %s"), *Result.Error);
return;
}
UE_LOG(LogTemp, Log, TEXT("Passport Initialized Successfully"));
}
UImmutablePassport* GetPassport() const
{
if (GetGameInstance())
{
auto Subsystem = GetGameInstance()->GetSubsystem();
if (Subsystem)
{
return Subsystem->GetPassport().Get();
}
}
return nullptr;
}
};
```
## Login
The `useLogin` hook provides embedded, popup, and redirect login flows. All functions accept an optional config; when omitted, sandbox defaults are used.
### Embedded Login
Shows an in-page modal for login method selection — no popup window required:
```tsx theme={null}
'use client';
import { useLogin, useImmutableSession, type LoginConfig } from '@imtbl/auth-next-client';
function LoginButton() {
const { loginWithEmbedded, isLoggingIn } = useLogin();
const { isAuthenticated } = useImmutableSession();
const loginConfig: LoginConfig = {
clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
redirectUri: `${window.location.origin}/callback`,
audience: 'platform_api',
scope: 'openid profile email offline_access transact',
authenticationDomain: 'https://auth.immutable.com',
};
if (isAuthenticated) return
Logged in
;
return (
);
}
```
### 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
# 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/products/hub/overview)
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 [Support](https://support.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
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 [Audience](/docs/products/audience/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 [Audience](/docs/products/audience/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
# 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 support team
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/hub/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 support team
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}
CFBundleURLTypesCFBundleURLSchemesmygame
```
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
Set up your project in Hub
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}
CFBundleURLTypesCFBundleURLSchemesmygame
```
**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}
CFBundleURLTypesCFBundleURLSchemesmygame
```
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}
CFBundleURLTypesCFBundleURLSchemesmygame
```
**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}
CFBundleURLTypesCFBundleURLSchemesmygame
```
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