# Get a single activity by ID
Source: https://docs.immutable.com/api-reference/activities/get-a-single-activity-by-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/activities/{activity_id}
Get a single activity by ID
# List all activities
Source: https://docs.immutable.com/api-reference/activities/list-all-activities
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/activities
List all activities
# List history of activities
Source: https://docs.immutable.com/api-reference/activities/list-history-of-activities
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/activity-history
List activities sorted by updated_at timestamp ascending, useful for time based data replication
# List supported chains
Source: https://docs.immutable.com/api-reference/chains/list-supported-chains
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains
List supported chains
# Get collection by contract address
Source: https://docs.immutable.com/api-reference/collections/get-collection-by-contract-address
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}
Get collection by contract address
# List all collections
Source: https://docs.immutable.com/api-reference/collections/list-all-collections
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections
List all collections
# List collections by NFT owner
Source: https://docs.immutable.com/api-reference/collections/list-collections-by-nft-owner
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/accounts/{account_address}/collections
List collections by NFT owner account address
# Refresh collection metadata
Source: https://docs.immutable.com/api-reference/collections/refresh-collection-metadata
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/collections/{contract_address}/refresh-metadata
Refresh collection metadata
# Refresh collection metadata internal
Source: https://docs.immutable.com/api-reference/collections/refresh-collection-metadata-internal
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/internal/chains/{chain_name}/environment/{environment}/collections/{contract_address}/refresh-metadata
Refresh collection metadata internal
# Sign a crafting payload
Source: https://docs.immutable.com/api-reference/crafting/sign-a-crafting-payload
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/crafting/sign
Sign a crafting payload
# Request erasure of all data for an identity
Source: https://docs.immutable.com/api-reference/data/request-erasure-of-all-data-for-an-identity
/api-specs/audience.json delete /v1/audience/data
Accepts an erasure request for the specified identity. All linked identities are resolved via stored alias mappings and an erasure event is queued for async processing. Provide either anonymousId or userId (not both).
Requires the project's secret API key, not the publishable key: this deletes another identity's data on the project's behalf, which a client holding only the (public) publishable key can never safely authorize.
# Ingest audience messages
Source: https://docs.immutable.com/api-reference/ingest/ingest-audience-messages
/api-specs/audience.json post /v1/audience/messages
# Get list of metadata attribute filters
Source: https://docs.immutable.com/api-reference/metadata-search/get-list-of-metadata-attribute-filters
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/search/filters/{contract_address}
Get list of metadata filters
# Search NFT stacks
Source: https://docs.immutable.com/api-reference/metadata-search/search-nft-stacks
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/search/stacks
Search NFT stacks
# Search NFTs
Source: https://docs.immutable.com/api-reference/metadata-search/search-nfts
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/search/nfts
Search NFTs
# Get a list of metadata from the given chain
Source: https://docs.immutable.com/api-reference/metadata/get-a-list-of-metadata-from-the-given-chain
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/metadata
Get a list of metadata from the given chain
# Get a list of metadata from the given contract
Source: https://docs.immutable.com/api-reference/metadata/get-a-list-of-metadata-from-the-given-contract
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/metadata
Get a list of metadata from the given contract
# Get metadata by ID
Source: https://docs.immutable.com/api-reference/metadata/get-metadata-by-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/metadata/{metadata_id}
Get metadata by ID
# List NFT stack bundles by stack_id. Response will include Market, Listings & Stack Count information for each stack
Source: https://docs.immutable.com/api-reference/metadata/list-nft-stack-bundles-by-stack_id-response-will-include-market-listings-&-stack-count-information-for-each-stack
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/stacks
List NFT stack bundles by stack_id. This endpoint functions similarly to `ListMetadataByID` but extends the response to include Market, Listings & Stack Count information for each stack.
# Refresh NFT metadata
Source: https://docs.immutable.com/api-reference/metadata/refresh-nft-metadata
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/collections/{contract_address}/nfts/refresh-metadata
Refresh NFT metadata
# Refresh stacked metadata
Source: https://docs.immutable.com/api-reference/metadata/refresh-stacked-metadata
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/collections/{contract_address}/metadata/refresh-metadata
Refresh stacked metadata
# List all NFT owners
Source: https://docs.immutable.com/api-reference/nft-owners/list-all-nft-owners
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/nft-owners
List all NFT owners on a chain
# List NFT owners by token ID
Source: https://docs.immutable.com/api-reference/nft-owners/list-nft-owners-by-token-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts/{token_id}/owners
List NFT owners by token ID
# List owners by contract address
Source: https://docs.immutable.com/api-reference/nft-owners/list-owners-by-contract-address
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/owners
List owners by contract address
# Get mint request by reference ID
Source: https://docs.immutable.com/api-reference/nfts/get-mint-request-by-reference-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests/{reference_id}
Retrieve the status of a mint request identified by its reference_id
# Get NFT by token ID
Source: https://docs.immutable.com/api-reference/nfts/get-nft-by-token-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts/{token_id}
Get NFT by token ID
# List all NFTs
Source: https://docs.immutable.com/api-reference/nfts/list-all-nfts
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/nfts
List all NFTs on a chain
# List mint requests
Source: https://docs.immutable.com/api-reference/nfts/list-mint-requests
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests
Retrieve the status of all mints for a given contract address
# List NFTs by account address
Source: https://docs.immutable.com/api-reference/nfts/list-nfts-by-account-address
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/accounts/{account_address}/nfts
List NFTs by account address
# List NFTs by contract address
Source: https://docs.immutable.com/api-reference/nfts/list-nfts-by-contract-address
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/collections/{contract_address}/nfts
List NFTs by contract address
# Mint NFTs
Source: https://docs.immutable.com/api-reference/nfts/mint-nfts
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests
Create a mint request to mint a set of NFTs for a given collection
# Cancel one or more orders
Source: https://docs.immutable.com/api-reference/orders/cancel-one-or-more-orders
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/cancel
Cancel one or more orders
# Create a bid
Source: https://docs.immutable.com/api-reference/orders/create-a-bid
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/bids
Create a bid
# Create a collection bid
Source: https://docs.immutable.com/api-reference/orders/create-a-collection-bid
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/collection-bids
Create a collection bid
# Create a listing
Source: https://docs.immutable.com/api-reference/orders/create-a-listing
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/listings
Create a listing
# Create a metadata bid
Source: https://docs.immutable.com/api-reference/orders/create-a-metadata-bid
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/metadata-bids
Create a metadata bid on NFTs matching a specific metadata identifier within a collection
# Create a trait bid
Source: https://docs.immutable.com/api-reference/orders/create-a-trait-bid
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/trait-bids
Create a trait bid on NFTs matching specific trait criteria within a collection
# Get a single bid by ID
Source: https://docs.immutable.com/api-reference/orders/get-a-single-bid-by-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/bids/{bid_id}
Get a single bid by ID
# Get a single collection bid by ID
Source: https://docs.immutable.com/api-reference/orders/get-a-single-collection-bid-by-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/collection-bids/{collection_bid_id}
Get a single collection bid by ID
# Get a single listing by ID
Source: https://docs.immutable.com/api-reference/orders/get-a-single-listing-by-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/listings/{listing_id}
Get a single listing by ID
# Get a single metadata bid by ID
Source: https://docs.immutable.com/api-reference/orders/get-a-single-metadata-bid-by-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/metadata-bids/{metadata_bid_id}
Get a single metadata bid by ID
# Get a single trade by ID
Source: https://docs.immutable.com/api-reference/orders/get-a-single-trade-by-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/trades/{trade_id}
Get a single trade by ID
# Get a single trait bid by ID
Source: https://docs.immutable.com/api-reference/orders/get-a-single-trait-bid-by-id
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/trait-bids/{trait_bid_id}
Get a single trait bid by ID
# List all bids
Source: https://docs.immutable.com/api-reference/orders/list-all-bids
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/bids
List all bids
# List all collection bids
Source: https://docs.immutable.com/api-reference/orders/list-all-collection-bids
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/collection-bids
List all collection bids
# List all listings
Source: https://docs.immutable.com/api-reference/orders/list-all-listings
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/listings
List all listings
# List all metadata bids
Source: https://docs.immutable.com/api-reference/orders/list-all-metadata-bids
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/metadata-bids
List all metadata bids
# List all trades
Source: https://docs.immutable.com/api-reference/orders/list-all-trades
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/trades
List all trades
# List all trait bids
Source: https://docs.immutable.com/api-reference/orders/list-all-trait-bids
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/orders/trait-bids
List all trait bids
# Retrieve fulfillment data for orders
Source: https://docs.immutable.com/api-reference/orders/retrieve-fulfillment-data-for-orders
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /v1/chains/{chain_name}/orders/fulfillment-data
Retrieve signed fulfillment data based on the list of order IDs and corresponding fees.
# Get all info for a Passport user
Source: https://docs.immutable.com/api-reference/passport-profile/get-all-info-for-a-passport-user
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /passport-profile/v1/user/info
Get all the info for an authenticated Passport user
# Get profile for authenticated user
Source: https://docs.immutable.com/api-reference/passport-profile/get-profile-for-authenticated-user
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /passport-profile/v1/profile
Get profile for the authenticated user's Passport wallet
# Link wallet v2
Source: https://docs.immutable.com/api-reference/passport-profile/link-wallet-v2
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /passport-profile/v2/linked-wallets
Link an external EOA wallet to an Immutable Passport account by providing an EIP-712 signature.
# Send phone OTP code for user supplied phone number
Source: https://docs.immutable.com/api-reference/passport-profile/send-phone-otp-code-for-user-supplied-phone-number
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /passport-profile/v1/phone-otp
Send phone OTP code for user supplied phone number
# Update username
Source: https://docs.immutable.com/api-reference/passport-profile/update-username
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /passport-profile/v1/username
Update username for the authenticated user's Passport wallet
# Verify phone OTP code against user phone number
Source: https://docs.immutable.com/api-reference/passport-profile/verify-phone-otp-code-against-user-phone-number
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json post /passport-profile/v1/phone-otp/verify
Verify phone OTP code for user supplied phone number
# Get Ethereum linked addresses for a user
Source: https://docs.immutable.com/api-reference/passport/get-ethereum-linked-addresses-for-a-user
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/passport/users/{user_id}/linked-addresses
This API has been deprecated, please use https://docs.immutable.com/zkevm/api/reference/#/operations/getUserInfo instead to get a list of linked addresses.
# Get pricing data for a list of stack ids
Source: https://docs.immutable.com/api-reference/pricing/get-pricing-data-for-a-list-of-stack-ids
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/quotes/{contract_address}/stacks
Get pricing data for a list of stack ids
# Get pricing data for a list of token ids
Source: https://docs.immutable.com/api-reference/pricing/get-pricing-data-for-a-list-of-token-ids
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/quotes/{contract_address}/nfts
pricing data for a list of token ids
# Get single ERC20 token
Source: https://docs.immutable.com/api-reference/tokens/get-single-erc20-token
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/tokens/{contract_address}
Get single ERC20 token
# List ERC20 tokens
Source: https://docs.immutable.com/api-reference/tokens/list-erc20-tokens
https://imx-openapiv3-mr-sandbox.s3.us-east-2.amazonaws.com/openapi.json get /v1/chains/{chain_name}/tokens
List ERC20 tokens
# Get tracking consent status
Source: https://docs.immutable.com/api-reference/tracking-consent/get-tracking-consent-status
/api-specs/audience.json get /v1/audience/tracking-consent
# Update tracking consent status
Source: https://docs.immutable.com/api-reference/tracking-consent/update-tracking-consent-status
/api-specs/audience.json put /v1/audience/tracking-consent
# Admin Security
Source: https://docs.immutable.com/docs/guides/admin-security
Secure wallet management and studio security best practices
## Wallet Management
### Wallet Types
| Wallet Type | Purpose | Security Level |
| -------------------- | ----------------------------- | ------------------------ |
| **Developer Wallet** | Testing, deploying to testnet | Low (hot wallet OK) |
| **Deployer Wallet** | Mainnet contract deployment | Medium (hardware wallet) |
| **Treasury Wallet** | Holding funds, revenue | High (multisig) |
| **Minter Wallet** | Backend minting operations | Medium (server-side) |
### Developer Wallet Setup
For local development and testnet:
#### MetaMask Setup
1. Install [MetaMask](https://metamask.io)
2. Add Immutable Chain Testnet:
* Network Name: `Immutable Testnet`
* RPC: `https://rpc.testnet.immutable.com`
* Chain ID: `13473`
* Symbol: `tIMX`
3. Get test tokens from the [Faucet](https://hub.immutable.com/faucet)
#### Hardhat Configuration
```typescript theme={null}
// hardhat.config.ts
import { HardhatUserConfig } from 'hardhat/config';
const config: HardhatUserConfig = {
};
export default config;
```
**Never commit `.env` files to version control.** Add `.env` and `.env.local` to your `.gitignore` file to prevent accidentally exposing private keys or API secrets.
## Production Treasury: Multisig
For production funds, use a multisig wallet like [Safe](https://safe.global).
### Why Multisig?
* **No single point of failure**: Multiple signatures required
* **Team accountability**: Track who approved transactions
* **Recovery**: Lost keys don't mean lost funds
### Setting Up Safe
1. Go to [Safe](https://app.safe.global)
2. Connect to Immutable Chain
3. Create a new Safe with:
* Multiple owner addresses (team members)
* Threshold (e.g., 2 of 3 signatures required)
4. Fund the Safe with IMX for gas
### Transaction Flow
```
Team Member A proposes transaction
↓
Team Member B reviews and signs
↓
Threshold reached → Transaction executes
```
## Backend Minting Wallet
Your backend needs a wallet for minting operations.
### Key Management Options
| Option | Pros | Cons |
| ------------------------ | ----------------- | ----------------------- |
| **Environment Variable** | Simple | Less secure |
| **AWS KMS** | Secure, auditable | More complex |
| **HashiCorp Vault** | Enterprise-grade | Infrastructure overhead |
### Using AWS KMS with viem
```typescript theme={null}
import { KMSClient, SignCommand } from '@aws-sdk/client-kms';
import { createWalletClient, http, type Account } from 'viem';
import { immutableZkEvm } from 'viem/chains';
// Create a KMS-backed account for viem
async function createKMSAccount(keyId: string): Promise {
const kms = new KMSClient({ region: 'us-east-1' });
return {
address: '0x...', // Derive from KMS public key
type: 'local',
signMessage: async ({ message }) => {
const command = new SignCommand({
KeyId: keyId,
Message: Buffer.from(message as string),
SigningAlgorithm: 'ECDSA_SHA_256',
MessageType: 'DIGEST',
});
const response = await kms.send(command);
// Process and return signature
return '0x...' as `0x${string}`;
},
signTransaction: async (tx) => {
// Sign transaction with KMS
return '0x...' as `0x${string}`;
},
signTypedData: async (data) => {
// Sign typed data with KMS
return '0x...' as `0x${string}`;
},
};
}
// Use with viem
const account = await createKMSAccount(process.env.KMS_KEY_ID!);
const walletClient = createWalletClient({
account,
chain: immutableZkEvm,
transport: http(),
});
```
### Rate Limiting & Monitoring
Protect your minting wallet:
```typescript theme={null}
import rateLimit from 'express-rate-limit';
const mintLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many mint requests',
});
app.post('/api/mint', mintLimiter, async (req, res) => {
// Log all mint operations
logger.info('Mint request', {
recipient: req.body.address,
tokenId: req.body.tokenId,
timestamp: new Date(),
});
// Process mint...
});
```
## Security Checklist
### Development
* [ ] Use separate wallet for testnet
* [ ] Never use mainnet private keys in development
* [ ] Clear browser wallet state between projects
### Production
* [ ] Treasury in multisig (Safe)
* [ ] Deployer wallet on hardware device
* [ ] Minting wallet keys in secure storage (KMS/Vault)
* [ ] Rate limiting on minting endpoints
* [ ] Monitoring and alerts for unusual activity
* [ ] Regular key rotation schedule
* [ ] Incident response plan documented
## Gas Management
### Estimating Costs
```typescript theme={null}
import { createPublicClient, http, formatEther } from 'viem';
import { immutableZkEvm } from 'viem/chains';
const publicClient = createPublicClient({
chain: immutableZkEvm,
transport: http(),
});
async function estimateGas(to: `0x${string}`, data: `0x${string}`) {
const gasEstimate = await publicClient.estimateGas({ to, data });
const gasPrice = await publicClient.getGasPrice();
const maxCost = gasEstimate * gasPrice;
console.log(`Max cost: ${formatEther(maxCost)} IMX`);
}
```
### Maintaining Balance
Set up alerts when wallet balance drops:
```typescript theme={null}
import { createPublicClient, http, formatEther, parseEther } from 'viem';
import { immutableZkEvm } from 'viem/chains';
const publicClient = createPublicClient({
chain: immutableZkEvm,
transport: http(),
});
async function checkBalance(address: `0x${string}`, threshold: bigint) {
const balance = await publicClient.getBalance({ address });
if (balance < threshold) {
await sendAlert(`Wallet ${address} balance low: ${formatEther(balance)} IMX`);
}
}
// Run periodically
setInterval(() => checkBalance(MINTER_WALLET, parseEther('10')), 60000);
```
## Funds Recovery
### From Passport Wallet
Players control their Passport wallets. Recovery is through their authentication provider (Google, Apple, etc.).
### From Studio Wallets
* **Multisig**: Recover with threshold of remaining signers
* **Single-sig**: Depends on backup strategy
* **KMS**: AWS manages key durability
Always test your recovery procedures before you need them.
## Next Steps
Deploy contracts from your secure wallet
Set up backend minting
# API Keys
Source: https://docs.immutable.com/docs/guides/advanced-setup/api-keys
Manage publishable and secret API keys for your Immutable project
## Key Types
| Key Type | Use Case | Security | Example Usage |
| ------------------- | ------------------------------ | -------------- | --------------------------------- |
| **Publishable Key** | Client-side SDK initialization | Safe to expose | Browser apps, game clients |
| **Secret Key** | Server-side operations | Keep private | Backend minting, admin operations |
## Publishable Key
```typescript theme={null}
import { Environment } from '@imtbl/config';
const baseConfig = {
environment: Environment.SANDBOX,
publishableKey: 'pk_imapik-your-publishable-key',
};
```
**Use for:**
* SDK initialization
* Client-side API calls
* [Passport](/docs/products/passport/overview) authentication
## Secret Key
```typescript theme={null}
// Server-side only
const headers = {
'x-immutable-api-key': process.env.IMX_SECRET_KEY,
};
const response = await fetch('https://api.immutable.com/v1/mint', {
method: 'POST',
headers,
body: JSON.stringify(mintRequest),
});
```
**Use for:**
* Minting NFTs
* Admin operations
* Webhook verification
* Any operation that modifies on-chain state
Never expose your Secret API Key in:
* Client-side JavaScript/TypeScript
* Mobile app code
* Public Git repositories
* Browser developer tools
Use environment variables on your server.
## Key Rotation
Rotate keys in [Hub](https://hub.immutable.com): **Settings** → **API Keys** → **Rotate Key**
Old key invalidates immediately
Rotate immediately if exposed
## Environment-Specific Keys
Each environment has its own keys:
| Environment | Publishable Key Prefix | Chain |
| ----------- | ----------------------- | ----------------------- |
| Sandbox | `pk_imapik-sandbox-...` | Immutable zkEVM Testnet |
| Production | `pk_imapik-...` | Immutable zkEVM Mainnet |
## Next Steps
Configure authentication clients
Invite members and manage roles
Use your secret key for minting
# Team Management
Source: https://docs.immutable.com/docs/guides/advanced-setup/team-management
Invite team members and manage permissions in Immutable Hub
## Invite Team Members
Invite in [Hub](https://hub.immutable.com): **Settings** → **Team** → **Invite Member**
## Roles and Permissions
| Role | Permissions |
| ---------- | --------------------------------------------------------------------------------------- |
| **Owner** | Full access. Can manage projects, team members, and member invitations. |
| **Member** | Same as Owner, except cannot manage team members (e.g. remove members or change roles). |
## Permission Matrix
| Action | Owner | Member |
| ------------------------------------------------------ | ----- | ------ |
| View projects | ✓ | ✓ |
| Create and delete projects | ✓ | ✓ |
| Deploy contracts | ✓ | ✓ |
| Manage API keys | ✓ | ✓ |
| Configure [Passport](/docs/products/passport/overview) | ✓ | ✓ |
| Invite members | ✓ | ✓ |
| Delete invitations | ✓ | ✓ |
| Remove team members | ✓ | - |
| Change member roles | ✓ | - |
## Audit Logs
Audit log tracks:
* [Contract](/docs/products/asset-contracts/overview) deployments
* API key rotations
* Team member changes
* [Passport](/docs/products/passport/overview) client modifications
View in **Settings** → **Audit Log**
## Organisation Structure
```
Organisation (Your Company)
├── Project A (Game 1)
│ ├── Sandbox environment
│ └── Production environment
├── Project B (Game 2)
│ ├── Sandbox environment
│ └── Production environment
└── Team Members
├── Owner
└── Members
```
## Best Practices
Periodically review team members and remove anyone who no longer needs access.
Use Sandbox for development and restrict Production access to senior team members.
## Next Steps
Manage API key access
Production readiness checklist
# Build a Game
Source: https://docs.immutable.com/docs/guides/build-a-game
To integrate with Immutable Audience, you will need to be a premium partner.
Contact us for a free demo and onboarding
To leverage Immutable's other products:
### Install SDK
npm install
Package Manager
Plugin installation
### Setup Passport
Instant identity and wallets for your users.
Configuration and onboarding
Sign in with Immutable (via existing social accounts or email)
Get wallet address, check balances, send transactions, sign messages
### Assets
Issue tradable assets to increase LTV and retention.
Deploy ERC-721 (NFT collection) and ERC-20 (currency) contracts
JSON schema, attributes, storage options (Immutable Hosted, IPFS, self-hosted) with optional on-chain validation
Server-side NFT minting for achievements, rewards, and starter packs
## Next Steps
Complete Passport documentation
Add funding options for players
Enable secondary market trading
Explore Unity and Unreal SDKs
# Build a Marketplace
Source: https://docs.immutable.com/docs/guides/build-a-marketplace
**Prerequisites**: Complete [Build a Game](/docs/guides/build-a-game) first to set up Passport, deploy contracts, and mint NFTs.
Build an in-game marketplace where players can fund wallets, view inventory, and trade NFTs.
### Wallet Funding
Enable players to add tokens to their wallets for trading.
Purchase tokens with fiat currency (powered by Transak)
Exchange tokens on zkEVM (powered by QuickSwap)
Transfer tokens from Ethereum L1 to zkEVM (powered by Squid)
### Display Inventory
Show players their NFT collections and assets.
Query NFT ownership, metadata, and trading history
Filter and search NFTs by attributes and properties
blockchain-data package for inventory queries
### Marketplace Trading
Enable peer-to-peer NFT trading with the Orderbook.
List NFTs for sale with ERC-20 token pricing
Purchase listed NFTs from other players
Remove NFTs from marketplace
Make offers on entire collections
Make offers on subsets of a collection using metadata filters
Make offers by metadata ID or field-level metadata criteria
Configure platform and royalty fees
Complete API reference and integration guides
### SDK Implementation
Full widget integration for web applications
# Immutable
Source: https://docs.immutable.com/docs/immutable
The growth platform for games—acquire players, automate engagement, and drive revenue
## Why Immutable?
Immutable is the growth platform for games: acquire players, automate engagement, and drive revenue across PC, console, and mobile.
We turn anonymous store and ad traffic into identified players you can message, measure, and monetize—especially for Steam launches, where wishlists alone do not give you an owned audience.
### Platforms
PC (Steam, Epic) · Console (PlayStation, Xbox) · Mobile (App Store, Google Play) · Community (Discord)
### Convert
Capture emails, wishlists, socials, and consent through an optimized funnel. Unify Steam, Discord, and UA traffic into owned identities you can reach again—not anonymous storefront counts.
### Engage
Run lifecycle campaigns from signup through launch and beyond. Message players you own instead of relying only on storefront notifications.
### Measure
See which channels produce players, not just clicks. Attribute signups and in-game outcomes so you can shift spend to what pays back.
## Core Products
Collect, unify, and activate player profiles
Measure campaigns on those same profiles
Indexer, Orderbook, Checkout, and Asset Contracts
Authentication and embedded wallets
Player discovery, rewards, and questing
## SDKs
Web & backend
Unity games
Unreal Engine
# Crafting
Source: https://docs.immutable.com/docs/products/asset-contracts/crafting
Let players combine existing NFTs to create new ones—a core mechanic for progression and engagement in games.
## Why Use Crafting?
Crafting drives collection and trading behaviour. Players actively hunt for ingredients, creating demand across your economy.
Burning inputs removes items from circulation, maintaining scarcity and value in your game economy.
Multi-step recipes create meaningful goals. Players feel invested when they craft rare items.
## How It Works
Player chooses NFTs to combine from their inventory
Your server verifies ownership and recipe validity
Transfer input NFTs to burn address
Create new NFT for player via [Minting API](/docs/products/asset-contracts/minting-api)
## Recipe System
Define recipes that map inputs to outputs:
```typescript theme={null}
const recipes = [
{
id: 'iron_sword',
inputs: [
{ contract: MATERIALS, trait: { type: 'Iron Ore' }, count: 2 },
{ contract: MATERIALS, trait: { type: 'Wood' }, count: 1 },
],
output: {
name: 'Iron Sword',
attributes: [{ trait_type: 'Damage', value: 10 }],
},
},
{
id: 'steel_sword',
inputs: [
{ contract: WEAPONS, trait: { type: 'Iron Sword' }, count: 1 },
{ contract: MATERIALS, trait: { type: 'Steel Ingot' }, count: 2 },
],
output: {
name: 'Steel Sword',
attributes: [{ trait_type: 'Damage', value: 25 }],
},
},
];
```
## Implementation
```typescript theme={null}
const BURN_ADDRESS = '0x000000000000000000000000000000000000dEaD';
app.post('/craft', async (req, res) => {
const { recipeId, inputTokenIds, playerAddress } = req.body;
const recipe = recipes.find(r => r.id === recipeId);
if (!recipe) {
return res.status(400).json({ error: 'Invalid recipe' });
}
// Verify ownership of all inputs
for (const tokenId of inputTokenIds) {
const owner = await nftContract.ownerOf(tokenId);
if (owner.toLowerCase() !== playerAddress.toLowerCase()) {
return res.status(403).json({ error: 'Not owner of input' });
}
}
// Verify inputs match recipe requirements
// ... validation logic
// Burn inputs
for (const tokenId of inputTokenIds) {
await nftContract.transferFrom(playerAddress, BURN_ADDRESS, tokenId);
}
// Mint output via Minting API
const outputId = generateTokenId();
await mintNFT(playerAddress, outputId, recipe.output);
res.json({ success: true, outputId });
});
```
## Upgrades
Enhance existing items by burning materials:
```typescript theme={null}
async function upgradeItem(tokenId: string, materialsUsed: string[]) {
const nft = await getNFT(tokenId);
const level = nft.attributes.find(a => a.trait_type === 'Level')?.value || 1;
// Burn material NFTs
for (const materialId of materialsUsed) {
await burnNFT(materialId);
}
// Update metadata via Minting API refresh
await refreshMetadata(tokenId, {
...nft.metadata,
attributes: nft.attributes.map(a =>
a.trait_type === 'Level' ? { ...a, value: level + 1 } : a
),
});
}
```
## Design Tips
| Pattern | Description |
| ----------------- | -------------------------------------- |
| **Deterministic** | Same inputs always produce same output |
| **Probabilistic** | Random chance for rare outcomes |
| **Time-gated** | Crafting takes time to complete |
| **Progressive** | Multi-step recipes for advanced items |
Keep crafting server-side to prevent exploitation. Only mint outputs after verifying ownership and burning inputs.
## Next Steps
Learn about the Minting API
Deploy NFT contracts
Use multi-token contracts
# Deploy Contracts
Source: https://docs.immutable.com/docs/products/asset-contracts/deploy-contracts
Deploy smart contracts through Hub without writing code
## Supported Contract Types
| Contract | Standard | Use Case |
| -------------------- | -------------------------------------------------- | ------------------------------------------------- |
| **ImmutableERC721** | [ERC-721](/docs/products/asset-contracts/erc721) | Unique NFTs (characters, items, land) |
| **ImmutableERC1155** | [ERC-1155](/docs/products/asset-contracts/erc1155) | Semi-fungible tokens (resources, stackable items) |
| **ImmutableERC20** | [ERC-20](/docs/products/asset-contracts/erc20) | Fungible tokens (in-game currency) |
## Deploy via Hub
Deploy contracts in [Hub](https://hub.immutable.com): **Contracts** → **Deploy Contract**
Contract types: ERC-721, ERC-1155, ERC-20
Gas fees sponsored by Immutable
## Pre-configured Features
| Feature | Description |
| --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| **Minting API Ready** | Works with [Immutable's Minting API](/docs/products/asset-contracts/minting-api) immediately |
| **[Operator Allowlist](/docs/products/asset-contracts/operator-allowlist)** | Pre-approved for Immutable marketplace contracts |
| **[Royalties](/docs/products/asset-contracts/royalties)** | EIP-2981 royalties configured |
| **Gas Sponsorship** | Compatible with Immutable's gas sponsorship |
## After Deployment
Set up metadata, enable minting, configure royalties
Backend minting requires [Secret API Key](/docs/guides/advanced-setup/api-keys)
NFT contracts
Multi-token contracts
Programmatic minting
Token metadata
## Deploy via Code
```typescript theme={null}
import { ImmutableERC721 } from '@imtbl/contracts';
// Deploy using Hardhat or your preferred framework
const contract = await ImmutableERC721.deploy(
owner,
name,
symbol,
baseURI,
contractURI,
operatorAllowlist,
royaltyReceiver,
royaltyPercentage
);
```
Learn more about custom contract deployment
# Deploy Contracts with Hardhat
Source: https://docs.immutable.com/docs/products/asset-contracts/deploy-contracts-with-hardhat
Deploy smart contracts to Immutable zkEVM using Hardhat, a popular Ethereum development environment.
**Use Immutable Contract Presets**: Immutable provides audited, production-ready contract presets that include built-in royalty enforcement, operator allowlists, and metadata management. See the [ERC-721](/docs/products/asset-contracts/erc721), [ERC-1155](/docs/products/asset-contracts/erc1155), and [ERC-20](/docs/products/asset-contracts/erc20) preset documentation for details.
## Immutable zkEVM Collection Requirements
All NFT collections deployed on Immutable zkEVM must implement:
* **Royalty Enforcement**: EIP-2981 royalty standard with on-chain enforcement via the Operator Allowlist
* **Operator Allowlist**: Restrict transfers to approved marketplaces and platforms
* **Metadata Standards**: ERC-721/ERC-1155 metadata standards with proper token/collection URIs
Immutable's preset contracts handle these requirements automatically. If using custom contracts, ensure they meet these standards.
See the [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist) and [Royalties](/docs/products/asset-contracts/royalties) documentation for detailed implementation guidance.
## Setup Hardhat
Install and configure Hardhat following the [official Hardhat setup guide](https://hardhat.org/hardhat-runner/docs/getting-started#installation). Install the toolbox and `dotenv` packages:
```bash theme={null}
npm install --save-dev @nomicfoundation/hardhat-toolbox dotenv
```
## Configure Hardhat
To deploy contracts to Immutable zkEVM, configure your `hardhat.config.ts` file with network settings:
```typescript theme={null}
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
import * as dotenv from "dotenv";
dotenv.config();
const config: HardhatUserConfig = {
solidity: {
version: "0.8.19",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
};
export default config;
```
**Network Details:**
| Network | RPC URL | Chain ID |
| ----------- | ----------------------------------- | -------- |
| **Testnet** | `https://rpc.testnet.immutable.com` | 13473 |
| **Mainnet** | `https://rpc.immutable.com` | 13371 |
The deployer account must have sufficient IMX for gas fees. Get testnet IMX from the [Immutable Testnet Faucet](https://portal.immutable.com/faucet).
### Environment Variables
Create a `.env` file in your project root:
```bash theme={null}
PRIVATE_KEY=your_wallet_private_key_here
```
**Never commit `.env` files to version control.** Add `.env` and `.env.local` to your `.gitignore` file to prevent accidentally exposing private keys or API secrets.
## Add Contract
After installing the preset contract library (`npm install @imtbl/contracts`), create a `contracts` directory and add your contract file.
**Solidity Version**: The preset contracts use Solidity `0.8.19`. Ensure your `hardhat.config.ts` has `version: "0.8.19"` in the `solidity` section.
**Operator Allowlist Addresses** (required constructor parameter):
| Network | Operator Allowlist Address |
| ----------- | -------------------------------------------- |
| **Testnet** | `0x6b969FD89dE634d8DE3271EbE97734FEFfcd58eE` |
| **Mainnet** | `0x1B16F1Da2E5DF531512E15F68c86ac0A7C2a6929` |
The Operator Allowlist restricts NFT transfers to approved marketplaces. For details on how it works and how to manage the allowlist, see [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist).
Create `contracts/MyERC721.sol`:
```solidity theme={null}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@imtbl/contracts/contracts/token/erc721/preset/ImmutableERC721.sol";
contract MyERC721 is ImmutableERC721 {
constructor(
address owner_,
string memory name_,
string memory symbol_,
string memory baseURI_,
string memory contractURI_,
address operatorAllowlist_,
address royaltyReceiver_,
uint96 feeNumerator_
)
ImmutableERC721(
owner_,
name_,
symbol_,
baseURI_,
contractURI_,
operatorAllowlist_,
royaltyReceiver_,
feeNumerator_
)
{}
}
```
Create `contracts/MyERC1155.sol`:
```solidity theme={null}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@imtbl/contracts/contracts/token/erc1155/preset/ImmutableERC1155.sol";
contract MyERC1155 is ImmutableERC1155 {
constructor(
address owner_,
string memory name_,
string memory baseURI_,
string memory contractURI_,
address operatorAllowlist_,
address royaltyReceiver_,
uint96 feeNumerator_
)
ImmutableERC1155(
owner_,
name_,
baseURI_,
contractURI_,
operatorAllowlist_,
royaltyReceiver_,
feeNumerator_
)
{}
}
```
## Compile
Compile your contracts to generate artifacts and type definitions:
```bash theme={null}
npx hardhat compile
```
**Output:**
```
Compiling...
Compiled 1 contract successfully
```
This generates:
* Contract artifacts in `artifacts/`
* ABI files for contract interaction
## Test
Write tests in the `test/` directory using Hardhat's testing framework (Mocha and Chai):
```typescript theme={null}
import { expect } from "chai";
import { ethers } from "hardhat";
describe("MyERC721", function () {
it("Should deploy with correct name and symbol", async function () {
const [owner] = await ethers.getSigners();
const MyERC721 = await ethers.getContractFactory("MyERC721");
const contract = await MyERC721.deploy(
owner.address,
"My Collection",
"MYC",
"https://example.com/metadata/",
"https://example.com/collection.json",
"0x6b969FD89dE634d8DE3271EbE97734FEFfcd58eE", // Testnet allowlist
owner.address,
200 // 2% royalty
);
await contract.waitForDeployment();
expect(await contract.name()).to.equal("My Collection");
expect(await contract.symbol()).to.equal("MYC");
});
});
```
Run tests:
```bash theme={null}
npx hardhat test
```
Hardhat automatically compiles contracts before running tests, so `npx hardhat compile` is not required before testing.
## Deploy
### Write Deployment Script
**Important**: Immutable zkEVM requires specific gas configuration. Always include transaction overrides with `maxFeePerGas`, `maxPriorityFeePerGas`, and `gasLimit`.
Create `scripts/deploy.ts`:
```typescript theme={null}
import { ethers } from "hardhat";
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with account:", deployer.address);
console.log("Account balance:", (await ethers.provider.getBalance(deployer.address)).toString());
const MyERC721 = await ethers.getContractFactory("MyERC721");
const contract = await MyERC721.connect(deployer).deploy(
deployer.address, // owner
"My Collection", // name
"MYC", // symbol
"https://example.com/metadata/", // baseURI
"https://example.com/collection.json", // contractURI
"0x6b969FD89dE634d8DE3271EbE97734FEFfcd58eE", // operatorAllowlist (testnet)
deployer.address, // royaltyReceiver
200, // feeNumerator (2%)
{
maxPriorityFeePerGas: 10e9, // 10 gwei
maxFeePerGas: 15e9, // 15 gwei
gasLimit: 200000, // Set an appropriate gas limit for your transaction
}
);
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log("Contract deployed to:", address);
console.log("Transaction hash:", contract.deploymentTransaction()?.hash);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
```
```typescript theme={null}
import { ethers } from "hardhat";
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with account:", deployer.address);
console.log("Account balance:", (await ethers.provider.getBalance(deployer.address)).toString());
const MyERC1155 = await ethers.getContractFactory("MyERC1155");
const contract = await MyERC1155.connect(deployer).deploy(
deployer.address, // owner
"My Collection", // name
"https://example.com/metadata/", // baseURI
"https://example.com/collection.json", // contractURI
"0x6b969FD89dE634d8DE3271EbE97734FEFfcd58eE", // operatorAllowlist (testnet)
deployer.address, // royaltyReceiver
200, // feeNumerator (2%)
{
maxPriorityFeePerGas: 10e9, // 10 gwei
maxFeePerGas: 15e9, // 15 gwei
gasLimit: 200000, // Set an appropriate gas limit for your transaction
}
);
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log("Contract deployed to:", address);
console.log("Transaction hash:", contract.deploymentTransaction()?.hash);
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
```
**Constructor Parameters:**
* `owner`: Address with admin rights (typically deployer)
* `name`: Collection name (e.g., "My NFT Collection")
* `symbol`: Token symbol for ERC-721 (e.g., "MYC")
* `baseURI`: Base URI for token metadata (e.g., "[https://api.example.com/metadata/](https://api.example.com/metadata/)")
* `contractURI`: Collection-level metadata URI
* `operatorAllowlist`: Address of the Operator Allowlist contract (see table above)
* `royaltyReceiver`: Address that receives royalty payments
* `feeNumerator`: Royalty percentage in basis points (200 = 2%, 1000 = 10%)
### Deploy to Testnet
Deploy your contract to Immutable zkEVM testnet:
```bash theme={null}
npx hardhat run scripts/deploy.ts --network immutableZkevmTestnet
```
**Expected output:**
```
Deploying contracts with account: 0x1234...
Account balance: 1000000000000000000
Contract deployed to: 0x96dBDB46eCeEFd7082AE6461A83A6f08C8F5cd1C
Transaction hash: 0x5678...
```
Save the deployed contract address for the next steps.
### Deploy to Mainnet
**Production Deployment**: Test thoroughly on testnet before deploying to mainnet. Ensure:
* Contract has been audited if using custom logic
* Deployer account has sufficient IMX for gas fees
* All parameters (metadata URIs, allowlist address, royalty settings) are correct
* **Mainnet Operator Allowlist address**: `0x1B16F1Da2E5DF531512E15F68c86ac0A7C2a6929`
```bash theme={null}
npx hardhat run scripts/deploy.ts --network immutableZkevmMainnet
```
## Gas Pricing
Immutable zkEVM enforces a minimum gas price of 10 gwei to protect against spam traffic and ensure efficient transaction processing.
### Minimum Requirements
* **Minimum Gas Price**: Transactions with a tip cap below 10 gwei are rejected by the RPC
* **Fee Cap**: Must be greater than or equal to 10 gwei
* **Base Fee**: Transactions only mine when the gas fee cap is greater than or equal to the base fee
The base fee is generally expected to stay below the minimum gas tip cap.
### Recommended Gas Configuration
When deploying contracts, use the following gas settings:
```typescript theme={null}
{
maxPriorityFeePerGas: 10e9, // 10 gwei (minimum)
maxFeePerGas: 15e9, // 15 gwei
gasLimit: 200000, // Set an appropriate gas limit for your transaction
}
```
**Gas Parameters:**
* `maxPriorityFeePerGas`: Priority fee (tip) you're willing to pay to miners (minimum 10 gwei)
* `maxFeePerGas`: Maximum total fee per gas unit (base fee + priority fee)
* `gasLimit`: Maximum gas units the transaction can consume (set higher for contract deployments)
For advanced gas price optimization, use the [Ethereum RPC specification](https://ethereum.github.io/execution-apis/api-documentation/) `eth_feeHistory` method to analyze recent gas prices and adjust accordingly.
## Troubleshooting
### UNPREDICTABLE\_GAS\_LIMIT Error
**Error message:**
```bash theme={null}
reason: 'cannot estimate gas; transaction may fail or may require manual gas limit',
code: 'UNPREDICTABLE_GAS_LIMIT',
error: Error: gas required exceeds allowance or always failing transaction
```
**Causes:**
1. **Incorrect constructor arguments**: Verify all parameters are correct (especially addresses)
2. **Insufficient gas limit**: The default limit may be too low for complex contracts
3. **Contract logic error**: The contract may revert due to a bug
**Solutions:**
1. **Verify constructor arguments**: Double-check all addresses, strings, and numbers
2. **Estimate and increase gas limit**: First estimate the required gas, then set `gasLimit` accordingly:
```typescript theme={null}
// Estimate gas to determine required limit
const estimatedGas = await MyERC721.getDeployTransaction(...constructorArgs).then(tx =>
ethers.provider.estimateGas(tx)
);
console.log("Estimated gas:", estimatedGas.toString());
// Set gasLimit higher than estimate (add ~20% buffer)
const gasOverrides = {
maxPriorityFeePerGas: 10e9, // 10 gwei
maxFeePerGas: 15e9, // 15 gwei
gasLimit: Math.ceil(Number(estimatedGas) * 1.2),
};
```
3. **Test locally**: Run `npx hardhat test` to catch errors before deployment
## Link Contract to Immutable Hub
After deployment, link your contract via [Immutable Hub](https://hub.immutable.com) to enable metadata management, analytics, and Minting API support.
## Minting API Prerequisites
If your contract will use the [Minting API](/docs/products/asset-contracts/minting-api) for programmatic minting, grant the Minting API the required `MINTER_ROLE`:
```typescript theme={null}
// Get the Minting API address from Hub (under Contract > Minting API section)
const MINTING_API_ADDRESS = "0x..."; // From Hub
// Grant minter role
const MINTER_ROLE = await contract.MINTER_ROLE();
await contract.grantRole(MINTER_ROLE, MINTING_API_ADDRESS);
console.log("Minter role granted to Minting API");
```
**Preset Compatibility**: The Minting API has been rigorously tested with Immutable's preset contracts. If using custom contracts, thoroughly test compatibility. Immutable provides no compatibility guarantees for custom implementations.
See the [Minting API documentation](/docs/products/asset-contracts/minting-api) for complete setup instructions.
## Contract Verification
Verify your contract on Immutable Explorer to receive a green checkmark indicating legitimacy.
Go to your contract on [Immutable Explorer](https://explorer.immutable.com):
* **Testnet**: `https://explorer.testnet.immutable.com/address/YOUR_CONTRACT_ADDRESS`
* **Mainnet**: `https://explorer.immutable.com/address/YOUR_CONTRACT_ADDRESS`
Select the verification method (via source code or compiler settings).
Provide your contract source code and match the compiler settings from your `hardhat.config.ts`.
Once verified, your contract displays a green checkmark and users can read the source code directly on Explorer.
Verified contracts allow users to interact with contract functions directly through the Explorer UI, improving transparency and trust.
## Next Steps
Send transactions and interact with your deployed contracts using Passport
Mint NFTs programmatically using Immutable's Minting API
Configure token and collection metadata for your NFTs
Understand how royalties work and how to configure them
# ERC-1155 (Multi-Tokens)
Source: https://docs.immutable.com/docs/products/asset-contracts/erc1155
ERC-1155 tokens support both unique and stackable assets in one contract. Multiple copies of an item share the same token ID, making them ideal for consumables, crafting materials, and editions.
## Why Use ERC-1155?
Transfer multiple token types in a single transaction. Perfect for loot drops, rewards, and crafting systems.
Mix fungible (stackable) and non-fungible (unique) tokens in one contract. No need for multiple contracts.
More efficient storage and batch operations mean lower costs for games with many item types.
Players can hold 100 health potions as one balance entry, not 100 separate NFTs.
## Use Cases
* **Consumables**: Potions, ammo, food
* **Crafting Materials**: Ore, wood, gems
* **Editions**: Limited prints of artwork
* **Loot Boxes**: Reward bundles
* **Event Tickets**: Time-limited access tokens
## Deploy via Hub
Deploy ERC-1155 contracts in Hub
## Metadata
```json theme={null}
{
"name": "Health Potion",
"description": "Restores 50 HP",
"image": "https://assets.example.com/potion.png",
"attributes": [
{ "trait_type": "Type", "value": "Consumable" },
{ "trait_type": "Healing", "display_type": "number", "value": 50 }
]
}
```
## Minting
### Via [Minting API](/docs/products/asset-contracts/minting-api)
```bash theme={null}
curl -X POST '{baseURL}/v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests' \
-H 'x-immutable-api-key: YOUR_SECRET_KEY' \
-H 'Content-Type: application/json' \
-d '{
"assets": [{
"owner_address": "0xRecipient",
"token_id": "1",
"amount": "10",
"metadata": {
"name": "Health Potion",
"image": "https://assets.example.com/potion.png"
}
}]
}'
```
### Batch Different Token Types
```typescript theme={null}
const assets = [
{ owner_address: player, token_id: "1", amount: "5", metadata: healthPotion },
{ owner_address: player, token_id: "2", amount: "3", metadata: manaPotion },
{ owner_address: player, token_id: "3", amount: "10", metadata: ironOre },
];
await fetch(`${API_URL}/collections/${contract}/nfts/mint-requests`, {
method: 'POST',
headers: { 'x-immutable-api-key': secretKey },
body: JSON.stringify({ assets }),
});
```
## Fungible vs Non-Fungible Patterns
### Fungible (Stackable)
All health potions share `token_id: "1"` and are interchangeable:
```typescript theme={null}
// Mint 100 health potions
await mint({ token_id: "1", amount: "100", metadata: healthPotion });
// Player transfers 10 to friend
await contract.safeTransferFrom(player, friend, "1", 10, "0x");
```
### Non-Fungible (Unique)
Each legendary weapon gets a unique ID:
```typescript theme={null}
// Mint unique weapons
await mint({ token_id: "1001", amount: "1", metadata: uniqueSword });
await mint({ token_id: "1002", amount: "1", metadata: uniqueShield });
```
## Common Patterns
### Loot Box Rewards
```typescript theme={null}
async function openLootBox(playerAddress: string) {
const rewards = generateRandomRewards();
const assets = rewards.map((reward, i) => ({
owner_address: playerAddress,
token_id: reward.tokenId,
amount: String(reward.quantity),
}));
await mintBatch(assets);
}
```
## Deploy via Code
```solidity theme={null}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@imtbl/contracts/contracts/token/erc1155/preset/ImmutableERC1155.sol";
contract MyGameItems is ImmutableERC1155 {
constructor(
address owner,
string memory name,
string memory baseURI,
string memory contractURI,
address operatorAllowlist,
address royaltyReceiver,
uint96 royaltyFeeNumerator
) ImmutableERC1155(
owner, name, baseURI, contractURI,
operatorAllowlist, royaltyReceiver, royaltyFeeNumerator
) {}
}
```
## ERC-721 vs ERC-1155
| Feature | ERC-721 | ERC-1155 |
| ------------------ | ---------------- | ---------------------- |
| **Token Type** | Always unique | Unique or stackable |
| **Gas Efficiency** | Higher per token | Lower for batches |
| **Use Case** | Characters, land | Materials, consumables |
| **Complexity** | Simpler | More flexible |
## Next Steps
Mint tokens via API
Deploy via Hub
Explore contract options
Compare with ERC-721
# ERC-20 (In-Game Currencies)
Source: https://docs.immutable.com/docs/products/asset-contracts/erc20
ERC-20 tokens are fungible—each unit is identical and interchangeable. Use them for in-game currencies, reward points, governance tokens, or any divisible asset.
## Why Use ERC-20?
Players own their currency on-chain. They keep it even if they stop playing your game.
List on DEXes or enable peer-to-peer trading. Players can buy/sell currency with real market prices.
Same token can work across multiple games. Build partnerships and shared economies.
All supply, distribution, and transactions are verifiable on-chain. Players trust what they can verify.
## Deploy via Hub
Deploy ERC-20 contracts in Hub
## Deploy via Code
```solidity theme={null}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract GameCurrency is ERC20, Ownable {
constructor() ERC20("Gold Coins", "GOLD") Ownable(msg.sender) {
_mint(msg.sender, 1000000 * 10 ** decimals());
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
```
## Distribution Patterns
### Achievement Rewards
```typescript theme={null}
const REWARDS = {
'first_kill': 100n * 10n ** 18n,
'level_10': 500n * 10n ** 18n,
'boss_defeated': 1000n * 10n ** 18n,
};
async function grantAchievement(player: string, achievement: string) {
const reward = REWARDS[achievement];
if (reward) await currencyContract.mint(player, reward);
}
```
### Daily Login
```typescript theme={null}
async function claimDailyReward(player: string) {
const lastClaim = await getLastClaimTime(player);
if (Date.now() - lastClaim < 24 * 60 * 60 * 1000) {
throw new Error('Already claimed today');
}
await currencyContract.mint(player, 50n * 10n ** 18n);
await setLastClaimTime(player, Date.now());
}
```
## Spending Currency
### In-Game Purchases
```typescript theme={null}
async function purchaseItem(player: string, itemId: string, price: bigint) {
// Transfer from player to treasury
await currencyContract.transferFrom(player, TREASURY, price);
// Grant the item
await grantItem(player, itemId);
}
```
### Approval Flow
Players must approve your contract to spend their tokens:
```typescript theme={null}
// Frontend: Request approval
async function approveSpending(amount: bigint) {
const signer = await ethersProvider.getSigner();
const currency = new ethers.Contract(CURRENCY_ADDRESS, ERC20_ABI, signer);
await currency.approve(GAME_CONTRACT, amount);
}
```
## Balance Queries
```typescript theme={null}
// Direct contract call
const balance = await currencyContract.balanceOf(playerAddress);
console.log(`${ethers.formatEther(balance)} GOLD`);
// Via Indexer API
const tokens = await indexer.listTokensByAccountAddress({
chainName: 'imtbl-zkevm-mainnet',
accountAddress: playerAddress,
});
```
See [Indexer API](/docs/products/indexer/overview) for querying token balances.
## Economy Design
* Set maximum supply caps
* Implement burning mechanics (consumables, fees)
* Balance earn rates with spending sinks
* **Soft currency**: Earned through gameplay, unlimited
* **Hard currency**: Purchased or rare, limited supply
* **Seasonal currency**: Resets each season
* Rate limit claims per wallet
* Require game actions, not just API calls
* Monitor for suspicious patterns
## Next Steps
Deploy via Hub
Query token balances
Explore other contract types
Deploy programmatically
# ERC-721 (NFTs)
Source: https://docs.immutable.com/docs/products/asset-contracts/erc721
ERC-721 tokens are unique, non-fungible assets—each has a distinct ID and metadata. Use them for characters, weapons, land, collectibles, or any asset that should be one-of-a-kind.
## Why Use ERC-721?
Players own their assets on-chain. They can trade, sell, or use them across any compatible game or marketplace.
Fixed supply is enforced by the blockchain. Players can verify rarity and authenticity of any item.
Earn royalties on every resale. Set your percentage at deployment and receive automatic payments.
Standard ERC-721 works with all Ethereum wallets, marketplaces, and tools out of the box.
## Features
* **Unique Token IDs**: Each token has distinct metadata
* **EIP-2981 Royalties**: Automatic royalty enforcement
* **Operator Allowlist**: Control which contracts can transfer
* **Batch Minting**: Create multiple tokens in one transaction
* **[Minting API](/docs/products/asset-contracts/minting-api)**: Server-side minting at scale
## Deploy via Hub
Deploy ERC-721 contracts in Hub
## Metadata
Each NFT needs metadata following the ERC-721 standard. Metadata defines how items appear in wallets, marketplaces, and your game.
### Required Fields
```json theme={null}
{
"name": "Legendary Sword",
"description": "A powerful weapon forged in ancient fires",
"image": "https://assets.example.com/sword.png"
}
```
| Field | Required | Description |
| ------------- | -------- | ----------------------------------------- |
| `name` | Yes | Item's display name |
| `description` | Yes | Short description of the item |
| `image` | Yes | URL to item image (PNG, WEBP recommended) |
### Optional Fields
| Field | Description |
| ------------------ | ------------------------------------------------- |
| `animation_url` | URL to video, audio, or 3D asset (MP4, WEBM, GLB) |
| `external_url` | Link to item details on your website |
| `background_color` | Six-character hex (without #) for item background |
| `attributes` | Array of traits for filtering and display |
### Attributes
Use `attributes` to define item properties visible in marketplaces:
```json theme={null}
{
"name": "Flame Blade",
"description": "A legendary sword imbued with fire magic",
"image": "https://assets.example.com/flame-blade.png",
"attributes": [
{ "trait_type": "Rarity", "value": "Legendary" },
{ "trait_type": "Damage", "display_type": "number", "value": 150 },
{ "trait_type": "Element", "value": "Fire" },
{ "trait_type": "Level Requirement", "display_type": "number", "value": 25 }
]
}
```
### Attribute Display Types
Control how attributes appear in wallets and marketplaces:
| Display Type | Use For | Example Value | How It Displays |
| ------------------ | ------------------ | ------------- | --------------------- |
| (none) | Text values | `"Legendary"` | Rarity: Legendary |
| `number` | Numeric stats | `150` | Damage: 150 |
| `boost_percentage` | Percentage bonuses | `10` | Fire Resistance: +10% |
| `boost_number` | Numeric bonuses | `5` | Speed: +5 |
| `date` | Timestamps | `1672531200` | Minted: Jan 1, 2023 |
**Naming consistency:** Use the same `trait_type` across all items. "Rarity" everywhere, not "Rarity" for some items and "Tier" for others. This improves marketplace filtering.
### Metadata Hosting Options
#### Immutable Hosted (Recommended)
Include metadata directly in mint requests. Immutable stores and serves it automatically:
```json theme={null}
POST {baseURL}/v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests
{
"assets": [{
"owner_address": "0xRecipient",
"token_id": "1",
"metadata": {
"name": "Epic Sword",
"image": "https://assets.example.com/sword.png",
"attributes": [{ "trait_type": "Damage", "value": 100 }]
}
}]
}
```
| Environment | Base URL |
| ----------- | ----------------------------------- |
| **Testnet** | `https://api.sandbox.immutable.com` |
| **Mainnet** | `https://api.immutable.com` |
**Pros:**
* No hosting infrastructure required
* Automatic IPFS pinning for permanence
* Included in Minting API workflow
**Cons:**
* Metadata becomes immutable after minting
* Images still need external hosting
#### IPFS (Decentralized)
Upload metadata JSON to IPFS for permanent, decentralized storage:
**Pros:**
* Fully decentralized and permanent
* Content-addressed (tamper-proof)
* No ongoing hosting costs
**Cons:**
* Requires IPFS setup or pinning service (Pinata, NFT.Storage)
* Metadata cannot be updated
#### Self-Hosted
Host metadata on your own servers. Set `baseURI` during contract deployment:
**Pros:**
* Full control over metadata
* Can update metadata after minting
* Dynamic attributes possible
**Cons:**
* Hosting infrastructure required
* Centralization (server downtime affects metadata)
* Must maintain availability
## Minting
### Via [Minting API](/docs/products/asset-contracts/minting-api)
Enable Minting API access in [Hub](/docs/products/asset-contracts/deploy-contracts), then:
```bash theme={null}
curl -X POST '{baseURL}/v1/chains/{chain_name}/collections/{contract_address}/nfts/mint-requests' \
-H 'x-immutable-api-key: YOUR_SECRET_KEY' \
-H 'Content-Type: application/json' \
-d '{
"assets": [{
"owner_address": "0xRecipient",
"token_id": "1",
"metadata": {
"name": "Epic Sword",
"image": "https://assets.example.com/sword.png",
"attributes": [{ "trait_type": "Damage", "value": 100 }]
}
}]
}'
```
### Batch Minting
Mint up to 100 tokens per request:
```typescript theme={null}
const assets = Array.from({ length: 100 }, (_, i) => ({
owner_address: recipientAddress,
token_id: String(i + 1),
metadata: {
name: `Item #${i + 1}`,
image: `https://assets.example.com/${i + 1}.png`,
},
}));
await fetch(`${API_URL}/collections/${contract}/nfts/mint-requests`, {
method: 'POST',
headers: { 'x-immutable-api-key': secretKey },
body: JSON.stringify({ assets }),
});
```
## Deploy via Code
```solidity theme={null}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "@imtbl/contracts/contracts/token/erc721/preset/ImmutableERC721.sol";
contract MyGameNFT is ImmutableERC721 {
constructor(
address owner,
string memory name,
string memory symbol,
string memory baseURI,
string memory contractURI,
address operatorAllowlist,
address royaltyReceiver,
uint96 royaltyFeeNumerator
) ImmutableERC721(
owner, name, symbol, baseURI, contractURI,
operatorAllowlist, royaltyReceiver, royaltyFeeNumerator
) {}
}
```
## Next Steps
Sell NFTs directly to players
Let players combine items
# Minting API
Source: https://docs.immutable.com/docs/products/asset-contracts/minting-api
Mint NFTs at scale without managing nonces, batching, gas, or indexing—everything is handled through a single API call.
## Why Use the Minting API?
| Feature | Benefit |
| -------------------------- | --------------------------------------------------------------- |
| **Managed Infrastructure** | No nonce tracking, transaction lifecycle, or gas management |
| **Batch Optimization** | Multiple mint requests combined into gas-efficient transactions |
| **Metadata Indexing** | Metadata indexed immediately without crawling your `baseURI` |
| **Rate Limit Handling** | Built-in queuing with clear rate limit feedback |
| **Idempotent Requests** | Safe to retry with same `reference_id` |
The Minting API is a REST API. See the endpoint examples below for request/response formats.
## Prerequisites
ERC-721 or ERC-1155 contract
Grant minter role to Immutable
Secret API Key for authentication
The Minting API requires [Immutable preset contracts](/docs/products/asset-contracts/overview). Custom contracts are not supported.
**Security:** Keep your Secret API Key server-side only. See [API Keys Security](/docs/guides/advanced-setup/api-keys#secret-key) for best practices.
## Rate Limits
| Tier | NFTs/SFTs per minute | Burst | Use Case |
| -------------- | -------------------- | ------ | -------------------- |
| **Standard** | 200 | 2,000 | Testing, small mints |
| **Partner** | 2,000 | 20,000 | Production games |
| **Enterprise** | Custom | Custom | High-volume launches |
Rate limits count distinct `token_id`s, not total tokens. Minting 1000 copies of the same ERC-1155 `token_id` counts as 1 request.
Rate limit headers in response:
```json theme={null}
{
"imx_mint_requests_limit": "2000",
"imx_remaining_mint_requests": "1999",
"imx_mint_requests_limit_reset": "2024-02-13 07:20:00 UTC",
"imx_mint_requests_retry_after": "59.98-seconds"
}
```
## Minting ERC-721
### With Token ID (mintBatch)
Use when you need specific `token_id`s (e.g., migrating existing assets):
```typescript theme={null}
// Types
interface MintMetadata {
name: string;
description?: string;
image: string;
external_url?: string;
animation_url?: string;
attributes?: Array<{
trait_type: string;
value: string | number;
display_type?: 'number' | 'boost_percentage' | 'boost_number' | 'date';
}>;
}
interface MintAsset {
reference_id: string;
owner_address: string;
token_id?: string;
metadata?: MintMetadata;
}
const BASE_URL = 'https://api.sandbox.immutable.com'; // or api.immutable.com for mainnet
const CHAIN_NAME = 'imtbl-zkevm-testnet'; // or imtbl-zkevm-mainnet
// Mint ERC-721 with token_id specified
export async function mintERC721WithTokenId(
contractAddress: string,
assets: MintAsset[],
secretApiKey: string
) {
const response = await fetch(
`${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/mint-requests`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-immutable-api-key': secretApiKey,
},
body: JSON.stringify({ assets }),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Mint failed: ${error.message}`);
}
return response.json();
}
// Example: Mint a single NFT with metadata
export async function mintSingleNFT(
contractAddress: string,
recipientAddress: string,
tokenId: string,
metadata: MintMetadata,
secretApiKey: string
) {
const assets: MintAsset[] = [
{
reference_id: tokenId,
owner_address: recipientAddress,
token_id: tokenId,
metadata,
},
];
return mintERC721WithTokenId(contractAddress, assets, secretApiKey);
}
```
### Without Token ID (mintBatchByQuantity)
More gas-efficient. System generates sequential `token_id`s:
```typescript theme={null}
const response = await fetch(
`https://api.immutable.com/v1/chains/imtbl-zkevm-mainnet/collections/${contractAddress}/nfts/mint-requests`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-immutable-api-key': secretApiKey,
},
body: JSON.stringify({
assets: [
{
reference_id: 'reward-123', // Your internal ID
owner_address: playerAddress,
// No token_id - system assigns one
metadata: {
name: 'Daily Reward',
image: 'https://assets.example.com/reward.png',
},
},
],
}),
}
);
```
Use the `reference_id` to look up the assigned `token_id` after minting completes.
`mintBatchByQuantity` may return incomplete `balanceOf()` results for collections with 80,000+ tokens. Contact Immutable if you need both features.
## Minting ERC-1155
```typescript theme={null}
interface MintAssetERC1155 {
reference_id: string;
owner_address: string;
token_id: string;
amount: string;
metadata?: {
name: string;
description?: string;
image: string;
attributes?: Array<{ trait_type: string; value: string | number }>;
};
}
const BASE_URL = 'https://api.sandbox.immutable.com';
const CHAIN_NAME = 'imtbl-zkevm-testnet';
// Mint ERC-1155 tokens
export async function mintERC1155(
contractAddress: string,
assets: MintAssetERC1155[],
secretApiKey: string
) {
const response = await fetch(
`${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/mint-requests`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-immutable-api-key': secretApiKey,
},
body: JSON.stringify({ assets }),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Mint failed: ${error.message}`);
}
return response.json();
}
// Example: Mint starter pack with multiple item types
export async function mintStarterPack(
contractAddress: string,
recipientAddress: string,
secretApiKey: string
) {
const starterPackItems: MintAssetERC1155[] = [
{
reference_id: `starter-sword-${Date.now()}`,
owner_address: recipientAddress,
token_id: '1', // Sword
amount: '1',
metadata: {
name: 'Iron Sword',
image: 'https://example.com/sword.png',
attributes: [{ trait_type: 'Attack', value: 10 }],
},
},
{
reference_id: `starter-potion-${Date.now()}`,
owner_address: recipientAddress,
token_id: '2', // Health Potion
amount: '5', // Give 5 potions
metadata: {
name: 'Health Potion',
image: 'https://example.com/potion.png',
},
},
];
return mintERC1155(contractAddress, starterPackItems, secretApiKey);
}
```
### Key Differences from ERC-721
| Aspect | ERC-1155 |
| -------------------- | --------------------------------------- |
| `token_id` | Always required |
| `amount` | Required (number of tokens) |
| Metadata | Only on **first** mint for a `token_id` |
| Duplicate `token_id` | Allowed (mints additional quantity) |
Passing metadata when minting to an existing `token_id` returns `409 Conflict`. Check if the token exists before including metadata.
## Tracking Mint Status
```typescript theme={null}
interface MintRequestStatus {
chain: { id: string; name: string };
collection_address: string;
reference_id: string;
status: 'pending' | 'succeeded' | 'failed';
token_id: string | null;
owner_address: string;
transaction_hash: string | null;
error: string | null;
}
const BASE_URL = 'https://api.sandbox.immutable.com';
const CHAIN_NAME = 'imtbl-zkevm-testnet';
// Get mint request status by reference_id
export async function getMintStatus(
contractAddress: string,
referenceId: string,
secretApiKey: string
): Promise {
const response = await fetch(
`${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/mint-requests/${referenceId}`,
{
headers: { 'x-immutable-api-key': secretApiKey },
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Status check failed: ${error.message}`);
}
const data = await response.json();
return data.result[0];
}
// Poll for mint completion with timeout
export async function waitForMint(
contractAddress: string,
referenceId: string,
secretApiKey: string,
maxAttempts = 30,
intervalMs = 2000
): Promise {
for (let i = 0; i < maxAttempts; i++) {
const status = await getMintStatus(contractAddress, referenceId, secretApiKey);
if (status.status === 'succeeded') {
console.log(`Mint succeeded! Token ID: ${status.token_id}`);
return status;
}
if (status.status === 'failed') {
throw new Error(`Mint failed: ${status.error}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`Mint timed out after ${maxAttempts} attempts`);
}
```
### Status Values
| Status | Meaning |
| ----------- | ----------------------------------------------------------- |
| `pending` | Request queued, waiting for blockchain confirmation |
| `succeeded` | Mint completed, `token_id` and `transaction_hash` available |
| `failed` | Mint failed, see `error` field |
### Webhooks (Recommended)
Configure webhooks in [Hub](https://hub.immutable.com) to receive real-time updates:
```typescript theme={null}
// Webhook payload for imtbl_zkevm_mint_request_updated
{
event_name: 'imtbl_zkevm_mint_request_updated',
data: {
reference_id: 'reward-123',
status: 'succeeded',
token_id: '456',
transaction_hash: '0x...',
collection_address: '0x...',
owner_address: '0x...',
}
}
```
## Metadata
### Include with Mint Request (Recommended)
Include metadata in the mint request for immediate indexing:
```typescript theme={null}
{
assets: [{
reference_id: '123',
owner_address: playerAddress,
token_id: '123',
metadata: {
name: 'Legendary Sword',
description: 'A powerful weapon',
image: 'https://assets.example.com/sword.png',
animation_url: 'https://assets.example.com/sword.mp4',
attributes: [
{ trait_type: 'Rarity', value: 'Legendary' },
{ trait_type: 'Damage', value: 100, display_type: 'number' },
],
},
}],
}
```
### Host at baseURI (Required)
Even when including metadata in the API call, you **must** also host the metadata file at `{baseURI}/{token_id}`. Some ecosystem partners fetch directly from the source.
See the metadata format specification
## Refreshing Metadata
When in-game assets evolve through player actions (leveling up, crafting, performance enhancements), you need to refresh the metadata so marketplaces and other ecosystem partners reflect the updated attributes.
Immutable does not listen for URI update events. You must explicitly trigger a metadata refresh for changes to propagate across the ecosystem.
### When to Refresh Metadata
| Use Case | Description |
| ------------------- | --------------------------------------------------- |
| **Leveling Up** | Character gains experience or unlocks new abilities |
| **Crafting** | Items are combined or upgraded |
| **Reveals** | Delayed reveal for primary sales or mystery boxes |
| **Balance Updates** | Adjusting game stats or rarity after launch |
| **Content Fixes** | Correcting art, names, or descriptions |
### Refresh by Token ID
Push updated metadata for specific tokens:
```typescript theme={null}
interface RefreshMetadataRequest {
nft_metadata: Array<{
token_id: string;
name: string;
description?: string;
image: string;
external_url?: string;
animation_url?: string;
attributes?: Array<{
trait_type: string;
value: string | number;
display_type?: 'number' | 'boost_percentage' | 'boost_number' | 'date';
}>;
}>;
}
const BASE_URL = 'https://api.sandbox.immutable.com'; // or api.immutable.com for mainnet
const CHAIN_NAME = 'imtbl-zkevm-testnet'; // or imtbl-zkevm-mainnet
export async function refreshNFTMetadata(
contractAddress: string,
metadata: RefreshMetadataRequest,
secretApiKey: string
) {
const response = await fetch(
`${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/refresh-metadata`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-immutable-api-key': secretApiKey,
},
body: JSON.stringify(metadata),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Metadata refresh failed: ${error.message}`);
}
// Check rate limit headers
console.log('Remaining refreshes:', response.headers.get('imx_remaining_refreshes'));
console.log('Limit resets at:', response.headers.get('imx_refresh_limit_reset'));
return response.json();
}
// Example: Update a leveled-up character
await refreshNFTMetadata(
contractAddress,
{
nft_metadata: [
{
token_id: '123',
name: 'Hero - Level 5',
description: 'A battle-hardened warrior',
image: 'https://assets.example.com/hero-level5.png',
attributes: [
{ trait_type: 'Level', value: 5, display_type: 'number' },
{ trait_type: 'Attack', value: 25, display_type: 'number' },
{ trait_type: 'Defense', value: 15, display_type: 'number' },
],
},
],
},
secretApiKey
);
```
Submit the **entire** asset metadata when refreshing—not just changed attributes. Omitted fields will be removed.
### Refresh Rate Limits
| Resource | Limit |
| ---------------------------------- | ---------------------------------------- |
| Total NFTs updated | 677 per minute |
| Token IDs per request | Up to 250 |
| Total metadata size | 228 KiB per request |
| Metadata stacks (by `metadata_id`) | 677 per minute, up to 10 IDs per request |
Rate limit response headers:
```json theme={null}
{
"imx_refreshes_limit": "10000",
"imx_refresh_limit_reset": "2024-02-13 15:04:05",
"imx_remaining_refreshes": "9000",
"retry_after": "2-seconds"
}
```
Use `metadata_id` for bulk updates. Refreshing by `metadata_id` updates all NFTs that share that metadata stack, which is significantly faster than refreshing individual tokens.
### Verify Metadata Updated
Metadata refreshes are asynchronous. Check the `metadata_synced_at` field via the [Indexer](/docs/products/indexer/overview) to confirm updates:
```typescript theme={null}
// Query the NFT to check when metadata was last synced
const nft = await fetch(
`${BASE_URL}/v1/chains/${CHAIN_NAME}/collections/${contractAddress}/nfts/${tokenId}`,
{ headers: { 'x-immutable-api-key': apiKey } }
).then((r) => r.json());
console.log('Metadata last synced:', nft.metadata_synced_at);
// Output: "2024-02-13T15:04:05.123Z"
```
Updated metadata is typically reflected on marketplaces within \~8 seconds.
## Error Handling
### Request Errors (No Blockchain Event)
| Error | Code | Cause |
| ------------------------- | ---- | ----------------------------------------------------- |
| `VALIDATION_ERROR` | 400 | >100 assets, duplicate `reference_id`, invalid format |
| `UNAUTHORISED_REQUEST` | 401 | Invalid API key |
| `AUTHENTICATION_ERROR` | 403 | Collection not linked to API key |
| `CONFLICT` | 409 | `reference_id` or `token_id` already used |
| `TOO_MANY_REQUESTS_ERROR` | 429 | Rate limit exceeded |
### Transaction Errors (Check via Status API)
| Error | Cause | Solution |
| -------------------------------------------- | ------------------------ | ----------------------------------------------- |
| `AccessControl: account ... is missing role` | Minter role not granted | Grant minter role to Immutable's minting wallet |
| `Exceeded maximum submissions` | Transaction retry failed | Resubmit the request |
### Idempotent Requests
Safe to retry failed network requests—duplicate `reference_id` returns `409 Conflict`:
```typescript theme={null}
try {
await mintRequest(assets);
} catch (error) {
if (error.status === 409) {
// Already processed - check status instead
const status = await getMintStatus(referenceId);
}
}
```
## Best Practices
### Batch Efficiently
```typescript theme={null}
// ✅ Good: Batch multiple assets in one request (up to 100)
await mint({ assets: items.slice(0, 100) });
// ❌ Bad: One request per asset
for (const item of items) {
await mint({ assets: [item] }); // Slow, wastes rate limit
}
```
### Use Reference IDs Wisely
```typescript theme={null}
// ✅ Good: Meaningful reference_id
reference_id: `reward-${eventId}-${playerId}`
// ❌ Bad: Random reference_id (hard to debug)
reference_id: crypto.randomUUID()
```
### Handle Rate Limits
```typescript theme={null}
// Check remaining quota before large batches
if (response.imx_remaining_mint_requests < batchSize) {
const resetTime = response.imx_mint_requests_limit_reset;
await waitUntil(resetTime);
}
```
## Next Steps
Backend minting in games
NFT contracts
Multi-token contracts
Query minted assets
Real-time mint notifications
# Operator Allowlist
Source: https://docs.immutable.com/docs/products/asset-contracts/operator-allowlist
The Operator Allowlist restricts which addresses can transfer your tokens, ensuring royalties are enforced by only allowing transfers through compliant marketplaces like [Immutable's Orderbook](/docs/products/orderbook/overview).
View the Operator Allowlist source code
## Why It's Required
The Operator Allowlist protects game studios from:
* **Vampire attacks**: Unauthorized marketplaces bypassing royalties
* **Protocol fee evasion**: Trading outside Immutable's ecosystem
* **Revenue loss**: Transactions that don't pay creator royalties
**Mandatory Compliance**: All ERC-721 and ERC-1155 collections on Immutable Chain must implement the Operator Allowlist.
### Non-Compliance Consequences
* Forfeit any token grants received
* Passport users see warnings that your collection may be counterfeit
* Exclusion from ecosystem marketplaces (TokenTrove, GameStop NFT, etc.)
## How It Works
The allowlist is a registry of approved operator addresses:
| Address Type | Example |
| -------------------------- | ------------------------------------- |
| **Contract Address** | Seaport settlement contract |
| **Bytecode Hash** | Smart contract wallet implementations |
| **Implementation Address** | Proxy contract targets |
### Enforcement Flows
**Approvals** (`approve`, `setApprovalForAll`):
| Scenario | Allowed |
| ------------------------------ | ------- |
| Target is an EOA (user wallet) | ✅ |
| Target has approved bytecode | ✅ |
| Target has approved address | ✅ |
| Unapproved smart contract | ❌ |
**Transfers** (`transferFrom`, `safeTransferFrom`):
| Scenario | Allowed |
| ------------------------------ | ------- |
| Caller is an EOA | ✅ |
| Caller is approved marketplace | ✅ |
| Unapproved contract | ❌ |
## Implementation
### Using Preset Contracts
All [Immutable preset contracts](/docs/products/asset-contracts/overview) and contracts deployed via [Hub](https://hub.immutable.com) include Operator Allowlist protection by default.
### Custom Contracts
For custom contracts, inherit from `OperatorAllowlistEnforced.sol`:
```solidity theme={null}
import "@imtbl/contracts/contracts/allowlist/OperatorAllowlistEnforced.sol";
contract MyNFT is ERC721, OperatorAllowlistEnforced {
constructor(address allowlist) {
_setOperatorAllowlistRegistry(allowlist);
}
function _beforeTokenTransfer(
address from,
address to,
uint256 tokenId
) internal override {
// Check operator allowlist for transfers
if (from != address(0) && to != address(0)) {
require(
_isAllowlisted(msg.sender),
"Operator not allowlisted"
);
}
super._beforeTokenTransfer(from, to, tokenId);
}
}
```
## Operator Allowlist Addresses
Get the current address from the Immutable API:
| Network | Chain ID | API Endpoint |
| ----------- | -------------- | ---------------------------------------------------------------------------- |
| **Testnet** | `eip155:13473` | `https://api.sandbox.immutable.com/v1/chains` → `operator_allowlist_address` |
| **Mainnet** | `eip155:13371` | `https://api.immutable.com/v1/chains` → `operator_allowlist_address` |
```bash theme={null}
# Get testnet address
curl https://api.sandbox.immutable.com/v1/chains | jq '.result[] | select(.name == "imtbl-zkevm-testnet") | .operator_allowlist_address'
# Get mainnet address
curl https://api.immutable.com/v1/chains | jq '.result[] | select(.name == "imtbl-zkevm-mainnet") | .operator_allowlist_address'
```
Immutable manages the allowlist with pre-approved addresses including Seaport and smart contract wallet deployments. Use this instead of deploying your own.
## Request Allowlist Addition
To add your contract to the Operator Allowlist:
Verify your contract on [Immutable Explorer](https://explorer.immutable.com/contract-verification). See the [verification guide](/docs/products/asset-contracts/erc721#deploy-via-code).
In [Hub](https://hub.immutable.com), go to **Contracts** and click **Link Contract**.
On your contract's detail page, click **Add to OAL** and follow the instructions.
* **Testnet**: Typically seconds (automated)
* **Mainnet**: Up to one week (manual review)
## Pre-Approved Operators
The following are already on the Operator Allowlist:
* [Immutable Orderbook](/docs/products/orderbook/overview)
* Immutable smart contract wallets
* Major ecosystem marketplaces
## Interface
The `IOperatorAllowlist` interface provides:
```solidity theme={null}
interface IOperatorAllowlist {
/// @notice Check if an address is allowlisted
/// @param target The address to check
/// @return bool True if allowlisted
function isAllowlisted(address target) external view returns (bool);
}
```
Configure royalty payments
Deploy a compliant contract
# Asset Contracts
Source: https://docs.immutable.com/docs/products/asset-contracts/overview
Pre-built smart contracts for NFTs and in-game currencies
Unique NFTs for characters, weapons, land, and collectibles
Multi-tokens for stackable items and editions
Fungible tokens for in-game currencies
## Why Use Immutable Contracts?
Audited contracts with proven security track record. Trusted by games managing millions in player assets.
Built-in royalties, operator allowlists, and batch operations designed specifically for game economies.
Deploy from Hub with automatic Minting API setup—no Solidity required.
Import `@imtbl/contracts` to extend base contracts with custom game logic.
## Deployment
Deploy contracts with zero code via [Hub](https://hub.immutable.com) or programmatically:
```bash theme={null}
npm install @imtbl/contracts
```
See individual contract pages for implementation examples.
## Operator Allowlist
Controls which contracts can transfer your NFTs. For details, see [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist).
[Immutable's Orderbook](/docs/products/orderbook/overview) and major marketplaces are pre-approved.
## Royalties
All contracts support EIP-2981 royalties:
* Set percentage at deployment (e.g., 5% = 500 basis points)
* Enforced on [Immutable's Orderbook](/docs/products/orderbook/overview)
* Automatic payment on secondary sales
## Verification
Get your collections verified to build trust with players and ecosystem partners.
### Verification Badge
Verified collections display a green checkmark across Immutable's ecosystem, indicating legitimacy to the community.
Link contracts to [Hub](https://hub.immutable.com) and request verification.
Verified collections display a green checkmark on marketplaces and ecosystem apps.
### Inactive Collections
Mark test or abandoned collections as **Inactive** in Hub to:
* Delist from marketplace search results
* Signal to players not to transact with these assets
* Keep your game's collection list clean
## Trading Rewards
Register your collections with Immutable's [Trading Rewards](https://www.digitalworldsnfts.com/trading-rewards) program to incentivize secondary market activity.
Collections must have royalties set to at least **0.5%** to be eligible. Contact your Immutable account manager to register.
View registered collections at [imx.community/rewards](https://imx.community/rewards?t=collections).
## Next Steps
Deploy contracts via Hub
Mint NFTs for your collections
Configure royalty payments
Enforce trading restrictions
# Royalties
Source: https://docs.immutable.com/docs/products/asset-contracts/royalties
Immutable's ecosystem enforces royalty fees to content creators when assets are bought and sold. Royalty fees are set during minting and configured in the smart contract.
View royalty implementation source code
## How Royalties Work
When an NFT or SFT is sold on the secondary market, a royalty fee automatically directs a portion of the transaction value to the original creator.
| Component | Description |
| ---------------------- | --------------------------------------------------------------------------- |
| **Royalty Percentage** | Set at contract deployment (e.g., 5% = 500 basis points) |
| **Recipient** | Address that receives royalties (can be a Fee Splitter) |
| **Standard** | EIP-2981 for on-chain royalty info |
| **Enforcement** | Via [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist) |
Royalty fees are optional—it's up to the collection owner. If set, Immutable's ecosystem enforces payment.
## Setting Royalties
### Via Hub
1. Go to [Hub](https://hub.immutable.com) → **Contracts** → **Deploy**
2. Select your contract type (ERC-721 or ERC-1155)
3. Configure:
* **Royalty Recipient**: Address to receive royalties
* **Royalty Percentage**: Percentage of sale price (e.g., 5%)
4. Deploy contract
### Via Code
```solidity theme={null}
import "@imtbl/contracts/contracts/token/erc721/preset/ImmutableERC721.sol";
contract MyNFT is ImmutableERC721 {
constructor(
address owner,
string memory name,
string memory symbol,
string memory baseURI,
string memory contractURI,
address operatorAllowlist,
address royaltyReceiver, // Royalty recipient
uint96 royaltyFeeNumerator // 500 = 5%
) ImmutableERC721(
owner, name, symbol, baseURI, contractURI,
operatorAllowlist, royaltyReceiver, royaltyFeeNumerator
) {}
}
```
## Fee Splitter
For distributing royalties to multiple recipients, use the Fee Splitter contract.
View the Fee Splitter source code
### Key Features
* **Multi-Recipient Support**: Allocate fees to multiple recipients
* **On-Chain Transparency**: Wallet addresses and shares stored on-chain
* **Gas Efficiency**: More efficient than real-time splitting
### How It Works
1. Set the Fee Splitter contract as your royalty recipient
2. Configure recipients and their percentage shares
3. Fees accumulate in the Fee Splitter contract
4. Call `releaseAll()` to distribute accumulated fees
```solidity theme={null}
// Releasing fees to all recipients
feeSplitter.releaseAll([USDC_ADDRESS, WETH_ADDRESS]);
// Release only native token (IMX)
feeSplitter.releaseAll();
```
### ERC-20 Allowlist
The Fee Splitter includes an allowlist for accepted tokens:
```solidity theme={null}
// Add token to allowlist
feeSplitter.addToAllowlist(ERC20_ADDRESS);
// Remove token from allowlist
feeSplitter.removeFromAllowlist(ERC20_ADDRESS);
```
Unlisted tokens sent to the Fee Splitter won't be distributed until added to the allowlist.
### Updating Allocation
Admin users can update fee allocation. When updated:
* All unreleased fees are redistributed according to the new configuration
* Existing minted assets automatically use the new configuration
Call `releaseAll()` before changing allocation to distribute fees according to the original configuration.
## Multiple Fee Splitters
Different scenarios require separate Fee Splitter contracts:
| Scenario | Solution |
| --------------------- | --------------------------------------------- |
| Different recipients | One contract per unique recipient combination |
| Different percentages | One contract per unique split ratio |
| Multiple games | One contract per game/collection |
## Royalty Enforcement
Royalties are enforced through the [Operator Allowlist](/docs/products/asset-contracts/operator-allowlist). Only allowlisted marketplaces (like [Immutable's Orderbook](/docs/products/orderbook/overview)) can transfer tokens, ensuring royalties are always paid.
Learn how royalty enforcement works
Deploy a contract with royalties
## Next Steps
Learn about royalty enforcement
Deploy via Hub with royalties
Explore contract options
Enable secondary trading
# Conversion Goals
Source: https://docs.immutable.com/docs/products/attribution/conversion-goals
Define which player actions count as a conversion so only the right events are forwarded to your connected ad networks
Conversion Goals are currently in **alpha**. Supported events and behavior may change between releases.
**Who is this for?** Marketers and game developers who want control over which in-game actions trigger postbacks to ad networks, rather than forwarding every supported event.
Conversion Goals let you define which player actions count as a conversion for your game. Only the actions you configure are forwarded to your connected ad networks.
## What Is a Goal?
A Goal is a rule that defines which player actions count as a conversion. For example:
* When a player completes a purchase, that counts as *First Purchase*
* When a player adds your game to their wishlist, that counts as *Wishlist Add*
* When a player submits their email, that counts as *Email Captured*
A Goal is made up of:
| Part | What it does |
| ---------------------------- | --------------------------------------------------------- |
| **Name** | A label you choose so you can find the Goal later |
| **Description** *(optional)* | A note describing what the Goal tracks |
| **Event** | The player action that can trigger the Goal |
| **Trigger** | Whether the Goal counts once per player or every time |
| **Filter** *(optional)* | Narrows the Goal to events with a specific property value |
Goals are scoped per game. A Goal you create for one game does not appear in another.
## How to Create a Goal
Open [Audience Hub](https://hub.immutable.com), navigate to your game, then go to **Settings** then **Goals**.
If your game has no Goals yet, the Goals page shows quick-start options for common actions such as first purchase, wishlist add, email capture, and first launch. These shortcuts pre-fill the event and trigger. They only appear while the Goals list is empty.
To configure a Goal yourself, select **Create goal**. The wizard has up to three steps:
Select **Built-in** to use one of Immutable's pre-built events, or select **Custom event** to use an event defined by your game.
For a custom event, you can choose an event seen in your game's data, reuse an event from another Goal, or enter a new event name.
Custom event names are case-sensitive and must exactly match the `eventName` your game sends. A Goal for `level_complete` does not match an event named `Level_Complete`.
Optionally narrow when the Goal fires. Built-in events provide their supported properties. For a custom event, enter the property name, select its type, choose an operator, and enter the value.
This step is skipped when the selected built-in event has no filterable properties.
Enter a name and optional description, confirm the event, trigger, and filters, then select **Create goal**.
## Triggers
| Trigger | What it does | Good for |
| ------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **Once per player** | Only the first time a player does the action counts. Later occurrences are ignored. | Wishlist add or email capture, which happen once per player |
| **Repeatable** | Every occurrence counts. | Purchases, where every purchase is a conversion |
When you create a Goal you choose **Once per player**, but a saved Goal displays this trigger as **First-time only**. The two labels mean the same thing.
## Filters
A filter narrows a Goal so it only counts events that match a specific property value. For example, count a purchase only when `value` is greater than 10. Filters are optional and can be combined with either trigger.
The available operators depend on the property type:
| Property type | Operators |
| -------------------------------------------------- | ---------------------------------- |
| Numeric (`value` or custom **Number**) | `=`, `≠`, `>`, `≥`, `<`, `≤`, `in` |
| String (`currency`, `game_id`, or custom **Text**) | `=`, `≠`, `in` |
| Boolean (custom **Boolean**) | `=`, `≠` |
You can add up to five filters to a Goal. Every filter must match for the Goal to fire.
## Built-in Events
Conversion Goals provides five built-in events. You can also create a Goal from a custom event your game sends. Filterable properties for built-in events depend on the event:
| Event | What it means | Filterable properties |
| ------------------- | ---------------------------------------------- | --------------------- |
| **Purchase** | A player completes a purchase | `currency`, `value` |
| **Wishlist add** | A player adds your game to their wishlist | `game_id` |
| **Wishlist remove** | A player removes your game from their wishlist | `game_id` |
| **Email acquired** | A player submits their email address | None |
| **Game launch** | A player launches your game | None |
## How Goals Connect to Postbacks
[Conversion Postbacks](/docs/products/attribution/postbacks/overview) are server-to-server callbacks Immutable sends to your connected ad networks when a conversion happens. A Goal defines what counts as a conversion; a postback is what Immutable sends when one occurs. A postback only fires when an attributed event matches an active Goal.
The path from a player action to an ad network:
Your game sends an event via the Unity SDK, Web SDK, or REST API.
Immutable's attribution engine checks whether the event is linked to a paid ad click captured by the Web SDK.
The event is checked against every active Goal for the game. If it matches a Goal's event, trigger, and filters, the conversion is recorded.
Immutable sends a postback for that conversion to every connected ad network. If no Goal matches, no postback is sent.
In Conversion Goals, a matching conversion fires to all connected ad networks. Per-Goal network selection is not available yet. To manage which networks receive postbacks, go to **Settings** then **Integrations**.
## Managing Goals
From **Settings** then **Goals** you can:
* **View** all active Goals and their configuration
* **Edit** a Goal's name, description, event, trigger, or filter. Changing the event clears any filter clauses, because they may not apply to the new event.
* **Archive** a Goal to stop it matching new events. Archiving is permanent and cannot be reversed. Archived Goals are kept for reference and do not count toward the active limit.
* **Filter** the list by active or archived status
Each game can have up to **50 active Goals**. Archived Goals do not count toward this limit. If you reach the cap, archive an existing Goal before creating a new one.
## Current Limitations
* **Exact custom event names.** Custom event matching is case-sensitive. Immutable does not correct misspelled or differently cased event names.
* **No retention-window triggers.** Conditions like "active on day 7" are not available yet.
* **All Goals fire to all connected networks.** Per-Goal network selection is not available yet.
## FAQ
Yes. You can edit a Goal's name, description, event, trigger, and filter at any time. If you change the event, any existing filter clauses are cleared because they may not apply to the new event.
Yes. Each matching Goal records its own conversion and sends its own postback. For example, two active Goals on the `purchase` event with different filters will each fire independently if a purchase satisfies both.
Archiving stops the Goal matching new events straight away. It cannot be reversed. Conversion records from before archiving are preserved, and archived Goals do not count toward the 50-active-Goal limit. To see archived Goals, switch the filter on the Goals page to **Archived**.
## Next Steps
Configure which ad networks receive postbacks when a Goal is matched
How Immutable attributes player actions back to ad campaigns
Send in-game conversion events from Unity builds
Send events from web games and marketing sites
Send events from your backend or game server
# Funnel Page
Source: https://docs.immutable.com/docs/products/attribution/funnel-page
High-conversion pre-launch landing page that feeds Audience CDP and Attribution
A Funnel Page is a dedicated landing experience on [Immutable Play](https://play.immutable.com). For **Steam games**, it sits beside the wishlist button: Steam still owns the store wishlist; you own a contactable audience you can nurture with [Engage](/docs/products/audience/engage) until (and after) launch.
Signups and links write into [Audience CDP](/docs/products/audience/overview) so Engage, [Analytics](/docs/products/audience/analytics/overview), and [Attribution](/docs/products/attribution/overview) share the same profiles.
See a live Funnel Page in action
## How it fits
| Product | Role of the Funnel Page |
| ---------------- | ----------------------------------------------------------------------------- |
| **Attribution** | Destination for paid and organic traffic you want to measure |
| **Audience CDP** | Automatic [ingestion](/docs/products/audience/ingestion) of signups and links |
| **Engage** | Lifecycle messaging against those signups |
Funnel Page traffic needs no separate marketing-site pixel. You can also serve the page from [your own domain](/docs/products/attribution/funnel-page-custom-domain), or [embed just the sign-up drawer](/docs/products/attribution/funnel-page-embed) on your existing site. To measure your own site or game client, add SDKs via Attribution / Ingestion.
## Why use one (especially with Steam)
| Steam wishlist / store page alone | Funnel Page + wishlist |
| --------------------------------- | -------------------------------------- |
| Anonymous intent on Steam | Known players in your CDP |
| Only Valve can email them | You message via Engage |
| Static store content | Content you control, updated anytime |
| UA optimizes for wishlists only | Attribution can track signup → install |
Keep the Steam wishlist CTA. Add the Funnel Page so paid and organic traffic also create an owned profile.
## How it works
Send paid and organic traffic to your Funnel Page instead of only Steam or your site
Players create accounts and link their social channels
Linked accounts and activity become Audience profiles
Multi-channel engagement expands reach
Convert engaged players into Day 1 actives
## Features
Connect Discord, X, Telegram, and more
Drop the sign-up drawer directly onto your own marketing site
Signups and links ingest into Audience CDP with no extra pixel
## Page components
* **Hero** — Branding, trailer, countdown, platform availability
* **Art gallery** — Screenshots and concept art
* **Community** — Fan count and community activity
## Acquisition multiplier
```
Traditional: 1000 clicks → 200 wishlists → 40 Day 1 players
Funnel Page: 1000 clicks → 200 signups → retention → more Day 1 players
```
Gains come from higher click-to-signup conversion, re-engagement, and multi-channel follow-up via Engage.
## Setup
Configure everything in [Hub](https://hub.immutable.com) — branding, content, and social channels — with no engineering work and no release cycle to wait for.
If you run Google Ads, add a **Google Tag ID** on the Funnel Page so sign-ups (Email Captured) are reported by `gtag`. Do not map that goal to [Google Ads postbacks](/docs/products/attribution/postbacks/google-ads). Postbacks should not be used for website events like Funnel Page sign-up.
The template is deliberately opinionated. It is tuned for one job, turning a visitor into a contactable signup, so you control the content and the branding rather than the layout. If that shape doesn't fit the campaign you have in mind, [use your own website](/docs/products/attribution/funnel-page-embed) instead and embed the sign-up drawer into a page you design yourself.
Editing is self-serve end to end:
* **Save Draft** — work on changes without touching the live page, and use **Live preview** to see them as players will
* **Publish** — push the draft live when you're ready, or unpublish the page again
* **Schedule publish** — pick a date and time instead, so a trailer drop or store-page beat goes out on cue rather than someone staying up for it. A pending schedule can be cancelled any time before it fires
* **Version history** — a record of every published version, so you can see exactly what changed and when
Set up your page in Hub
### Hosting options
Serve the page from your own domain instead of `play.immutable.com`
Put the sign-up drawer on your existing marketing site
## Pre-launch metrics
| Metric | Description |
| ------------------- | --------------------------- |
| **Signups** | Players on your Funnel Page |
| **Conversion rate** | Visitors → signups |
| **Engagement** | Return visits |
Deeper cohort and quality views live in [Audience Analytics](/docs/products/audience/analytics/overview). Source performance lives in [Attribution](/docs/products/attribution/overview).
## Next steps
Measure campaigns that drive Funnel Page traffic
How Funnel Page data enters the CDP
Nurture Funnel Page signups
# Host on your domain
Source: https://docs.immutable.com/docs/products/attribution/funnel-page-custom-domain
Serve your Funnel Page from your own domain instead of play.immutable.com
By default your [Funnel Page](/docs/products/attribution/funnel-page) is served at `play.immutable.com/games/{slug}`. Configure a custom domain in Hub to serve it from your own domain instead — useful when you want a paid campaign or a Steam store page's "learn more" link to land somewhere entirely first-party. Immutable provisions hosting and TLS; you keep editing the page in Hub exactly as before.
A custom domain replaces the whole path, not just the hostname in front of it. Once active, the page serves from `https://{yourdomain}/` directly — there is no `/games/{slug}` segment — and short links you create for this game point straight at the custom domain too.
## Configure in Hub
Link a game to your project first.
Go to your project in [Hub](https://hub.immutable.com) → **Settings** → **Custom Domains** → **Game page domain**.
Enter the domain you want your Funnel Page to serve from (for example `play.yourgame.com`). Both an apex domain (`yourgame.com`) and a subdomain (`play.yourgame.com`) are supported.
Click **Setup** to reveal the records to add at your DNS provider (see below), then add them.
With the records in place, click **I've done this**. Hosting, routing, and a TLS certificate are provisioned automatically — you never upload or manage a certificate yourself.
## DNS records
Records depend on whether you point an apex domain or a subdomain at your Funnel Page.
**Apex domain** (for example `yourgame.com`):
| Type | Name | Value |
| ----- | --------------- | ----------------------------------------------------------------- |
| A | `@` | Three static IP addresses (shown in Hub once you click **Setup**) |
| CNAME | `www` | A per-game hostname (shown in Hub) |
| CNAME | `_cf-challenge` | A per-game challenge hostname (shown in Hub) |
**Subdomain** (for example `play.yourgame.com`):
| Type | Name | Value |
| ----- | ------------------- | ---------------------------------- |
| CNAME | your full subdomain | A per-game hostname (shown in Hub) |
A subdomain is the simpler path — one CNAME record, and no certificate-challenge record to manage.
Once you've added the domain in Hub, it may also show a certificate validation CNAME — add that too. TLS issuance can take up to 30 minutes after that record appears.
Preview links for unpublished games always serve from `play.immutable.com`. A custom domain only ever serves the published Funnel Page, never `/preview`.
## Next steps
Define which player actions count as a conversion
Nurture the signups you capture
# Use your own website
Source: https://docs.immutable.com/docs/products/attribution/funnel-page-embed
Put the Funnel Page sign-up drawer on your existing marketing site with one script tag
Instead of sending traffic to a Play-hosted URL, you can embed the [Funnel Page](/docs/products/attribution/funnel-page) sign-up drawer directly into your own marketing site. A loader script mounts an overlay that opens on demand and carries the whole sign-up flow — email capture and one-time passcode (OTP) verification — without sending the visitor anywhere else.
## Prerequisite: a first-party subdomain
You delegate a subdomain to Immutable by CNAME (for example `landing.studio.com`), and Immutable serves the drawer from it. You then add a `
```
Replace `landing.studio.com` with your own delegated subdomain.
* Place the tag right before `
` already exists and the drawer can mount immediately. In `` it also works — it waits for `DOMContentLoaded` — but mounts slightly later.
* Use a real `
```
Each queued entry is `[methodName, ...args]`, using the same methods as above. After the loader runs, `window.imtbl.push([...])` keeps working as an alias for calling the method directly.
You don't need to queue anything just to load the drawer — the loader mounts its iframe as soon as it runs, so attribution is captured even for a visitor who never opens it.
## Events
Register handlers with `imtbl.embed.on(event, callback)`.
| Event | Fires when | Payload |
| ------------------ | ------------------------------------------------------------------------------------- | ---------------------------------- |
| `ready` | The drawer is connected and the API is usable. Fires once per page load; sticky. | none |
| `open` | The drawer becomes visible and interactive. | none |
| `close` | The drawer becomes hidden and click-through again. | none |
| `signup:started` | A visitor has submitted an email inside the drawer. | none |
| `signup:completed` | The visitor has verified their OTP. | none |
| `error` | The connection to the drawer failed and can't recover for the rest of this page load. | `{ code }` — see [Errors](#errors) |
`signup:completed` carries no email, because any third-party script on your page could observe the event. The verified address reaches you through an authenticated export instead.
## Errors
| Code | Meaning | Typical cause |
| ------------------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `handshake-timeout` | The drawer never answered the initial handshake. | CSP missing your delegated subdomain, or framing from outside your apex. |
| `version-mismatch` | The drawer answered with a wire-protocol version this loader doesn't understand. | Rare and transient — a browser holding a cached loader from just before a protocol change. Resolves on the next fetch. |
Either error is terminal for the rest of the page load: the drawer never becomes usable, and the loader stops intercepting clicks on `data-imtbl-cta` elements. An `` reverts to a plain link and navigates to its `href`; a `` with no `href` does nothing.
## Attribution and query parameters
The loader reads your page's query string and forwards it into the drawer's URL, so ad-network click IDs and UTM parameters survive into attribution. Everything forwards except:
* **Reserved keys.** `parentOrigin`, `ref`, `href`, and `lang` are populated by the loader from your page's context, so a same-named query parameter is ignored.
* **`iframe`, always.** Its presence tells Immutable's own scripts to skip initialization, so forwarding it would silently disable attribution.
* **Keys that look like personal data**, with a warning in [debug mode](#debugging) — whole-word matches for terms including `email`, `mail`, `phone`/`telephone`/`tel`, `fname`/`firstname`, `lname`/`lastname`/`surname`, `addr`/`address`, `passw`/`password`, `secret`, `ssn`, and `dob`/`birth`/`birthdate`. Matching is on separator and camelCase boundaries (`userEmail`, `user_email`, `firstname`), not raw substrings (`hotel_id` and `adobe_mc` are not caught).
* **Any value containing `@`**, regardless of key name.
* **Any single value over 512 characters**, plus anything beyond a **2048-character total budget** across everything forwarded — once spent, remaining parameters are dropped even if each would individually pass.
Only origin and path are forwarded as `href` — your page's query string and hash are stripped, so a value already dropped by the per-parameter scan can't slip back in.
Empty values are dropped as carrying no signal, except `onboarding` and `linkStatus` — presence alone is the signal there, so both `?onboarding` and `?onboarding=` forward (and a bare `?onboarding` link opens the drawer automatically).
## Debugging
Add `data-mode="debug"` to the script tag for console warnings prefixed `[imtbl-embed]` — dropped attribution parameters, handshake failures, and other loader-side problems:
```html theme={null}
```
Remove the attribute once you're done; the console stays silent by default.
| Symptom | Likely cause |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Nothing appears when you click the CTA, or the drawer never mounts | CSP — check `script-src` and `frame-src` include your delegated subdomain. If they do, confirm you're testing from a genuine subdomain of the apex you delegated. |
| Sign-up looks fine, then dies right after the OTP step | The delegated subdomain and the framing page don't share a registrable domain — see [Prerequisite](#prerequisite-a-first-party-subdomain). |
| Clicking a `data-imtbl-cta` element does nothing | Check the console in debug mode for `handshake-timeout` or `version-mismatch`. Once either fires the loader stops intercepting clicks, so an element with no `href` fallback does nothing. |
## Versioning and caching
The script is served at a permanent path, `/v1/embed.js` — a breaking change ships at `/v2/embed.js` rather than changing what `/v1/` returns underneath an integration you've already shipped.
Within `/v1/`, the contents are intentionally mutable: fixes are patched in place so every studio's integration gets them without editing their CMS. The tag therefore carries no Subresource Integrity (SRI) hash.
Responses are served with `Cache-Control: public, max-age=300, stale-while-revalidate=86400`.
## Next steps
Define which player actions count as a conversion
Nurture the signups you capture
# Attribution
Source: https://docs.immutable.com/docs/products/attribution/overview
Connect marketing spend to the players and conversions that matter
Attribution answers **which campaigns and channels produced which players and outcomes**. It uses the same event stream and profiles as [Audience CDP](/docs/products/audience/overview)—you instrument once, then measure spend here and activate players in the CDP.
For Steam titles, that means going beyond wishlist counts: credit the ads that created Funnel Page signups and, after ship, installs and in-game conversions—not only store wishlists you cannot message.
## What you get
| Question | Where in docs |
| --------------------------------------- | ---------------------------------------------------------------------------- |
| Which channels drive signups and value? | Models below + Hub **Sources** / **Campaigns** |
| Where should paid traffic land? | [Funnel Page](/docs/products/attribution/funnel-page) |
| What counts as a conversion? | [Conversion Goals](/docs/products/attribution/conversion-goals) |
| How do ad networks learn from outcomes? | [Conversion Postbacks](/docs/products/attribution/postbacks/overview) |
| How do we send the events? | SDKs below (same pipeline as [Ingestion](/docs/products/audience/ingestion)) |
## Audience CDP vs Attribution
| | **Audience CDP** | **Attribution** |
| ----------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| Job | Collect, unify, engage, analyze players | Credit acquisition and close the paid-media loop |
| Typical next step | [Engage](/docs/products/audience/engage), [Analytics](/docs/products/audience/analytics/overview) | Goals, postbacks, Funnel Page, source reports |
## Credit models
Credit paid, organic, influencer, referral, or direct for the signup.
Rank sources by paying and high-engagement players—not raw signup volume.
Example path: ad click → Discord → Funnel Page signup → email → game launch.
Player quality and segments are explored in [Audience Analytics](/docs/products/audience/analytics/overview); Attribution focuses on **source credit and conversion feedback**.
## Funnel Page
Send measurable traffic to a [Funnel Page](/docs/products/attribution/funnel-page) when you want a Play-hosted landing surface that also ingests into the CDP. For your own marketing site, use the Web SDK below.
## Paid media loop
1. Define [Conversion Goals](/docs/products/attribution/conversion-goals).
2. Connect networks and enable [Conversion Postbacks](/docs/products/attribution/postbacks/overview).
3. Use Hub source quality to exclude low-value traffic and build lookalikes.
4. Re-engage cold signups with [Engage](/docs/products/audience/engage).
Postbacks are server-to-server. No extra browser tags are required after the SDKs and Hub integrations are in place.
## Instrument once
Same prerequisites and Web SDK snippets used across Audience and Attribution:
* An [Immutable Hub](https://hub.immutable.com) account with a project
* A publishable key from your project settings ([API keys guide](/docs/guides/advanced-setup/api-keys))
### Install
```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](/docs/products/audience/analytics/web-sdk#content-security-policy) for the directives CDN users need to allow.
### Initialize
```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`.
Then identify players after login and send game events from Unity or REST as needed.
Sites and web games
Desktop and mobile Unity builds
Server-authoritative events
Event and attribution schemas
## In Hub
| Area | Use it for |
| ---------------- | ------------------------------- |
| **Sources** | Channel mix and quality |
| **Campaigns** | Performance by campaign |
| **Goals** | Conversion definitions |
| **Integrations** | Ad network postback connections |
## Next steps
Landing surface for measurable traffic
Define conversion events
Feed outcomes to ad networks
Ingestion, Engage, and Analytics
# Amazon Ads Postbacks
Source: https://docs.immutable.com/docs/products/attribution/postbacks/amazon-ads
Connect Amazon Ads via OAuth to automatically forward attributed conversions to the Amazon Ads Conversions API, covering Twitch, Fire TV, and Amazon DSP inventory
**Who is this for?** Marketers running paid acquisition on Amazon Ads (including Twitch, Fire TV, and Amazon DSP) who want to feed conversion data back to the platform to improve campaign optimisation.
Amazon Ads postbacks connect your Immutable game to the Amazon Ads Conversions API. When a player clicks your Amazon Ads campaign on Twitch, Fire TV, or Amazon DSP and completes a [Conversion Goal](/docs/products/attribution/conversion-goals), Immutable sends a server-to-server postback to Amazon automatically.
Twitch advertising runs through Amazon Ads and Amazon DSP. There is no separate Twitch measurement API. This integration covers all Amazon Ads inventory, including Twitch, Fire TV, Amazon Music, and broader Amazon DSP and Sponsored TV placements, through a single connection.
## What You Need
* The Immutable [Web SDK](/docs/products/audience/analytics/web-sdk) installed on your marketing site, so Amazon click IDs are captured
* The [Unity SDK](/docs/products/audience/analytics/unity-sdk), [Web SDK](/docs/products/audience/analytics/web-sdk), or [REST API](/docs/products/audience/analytics/rest-api) sending events from your game
* At least one active [Conversion Goal](/docs/products/attribution/conversion-goals) for the game
* An Amazon DSP account with Conversions API terms accepted and at least one Conversion Definition created
* Admin access to the game in [Audience Hub](https://hub.immutable.com)
## Step 1: Accept Conversions API Terms in Amazon DSP
Before Amazon will accept postbacks, the advertiser must accept the Conversions API terms of service once in the Amazon DSP Console:
1. Log in to [Amazon DSP](https://advertising.amazon.com/dsp).
2. Navigate to **Advertiser settings**.
3. Locate the **Conversions API** section and accept the terms.
This is a one-time step per advertiser account. Without it, Amazon will reject all conversion events.
## Step 2: Create Conversion Definitions in Amazon DSP
Amazon does not have a public API for creating Conversion Definitions, so they must be created manually in the Amazon DSP Console. Create one definition for each Goal you want to track:
1. In [Amazon DSP](https://advertising.amazon.com/dsp), go to **Conversions** then **Conversion definitions**.
2. Click **Create conversion definition** and configure the event type, name, and attribution settings.
3. After saving, note the **Conversion Definition ID** (a numeric string). You'll enter this in Hub for each Goal.
Conversion Definition IDs are numeric strings (for example, `1234567890`). You'll need one per active Goal you want to forward.
## Step 3: Connect Amazon Ads
1. In [Audience Hub](https://hub.immutable.com), navigate to **Settings** then **Integrations** then **Amazon Ads**.
2. In the **Conversion Postbacks** section, click **Connect Amazon Ads (Twitch, Fire TV, Amazon DSP)**.
3. A Login with Amazon authorization popup opens. Sign in to your Amazon account and grant the requested permissions.
4. Once authorized, Hub confirms the connection and closes the popup. Your advertiser region (NA, EU, or FE) is discovered automatically and pinned for all subsequent postbacks.
## Step 4: Map Goals to Conversion Definition IDs
After connecting, map each active Conversion Goal to the Conversion Definition you created in Amazon DSP:
1. On the Amazon Ads integration page, scroll to the **Conversion definition mapping** section.
2. For each active Goal, enter the numeric Conversion Definition ID (for example, `1234567890`).
3. Click **Save mapping**.
Hub validates that IDs are digits only before saving.
Every active Goal is mappable. Amazon receives a conversion event whenever an attributed in-game event matches any Goal that has a Conversion Definition ID configured.
## How Amazon Ads Conversions Work
Unlike other networks, the Amazon Ads Conversions API does not receive an event type from Immutable. Instead, the event semantics are carried by the Conversion Definition you created in Amazon DSP. The definition specifies the event type (for example, `OFF_AMAZON_PURCHASES` or `MOBILE_APP_FIRST_START`). Hub sends the numeric ID, and Amazon resolves the conversion against the definition's settings.
This means the mapping in Hub is always per-Goal to per-definition: one Goal, one Conversion Definition ID.
For `purchase` conversions, Immutable forwards `value` and `currency` when both are present, so Amazon can optimise for revenue (ROAS) rather than conversion count alone.
Amazon silently drops conversions timestamped more than 7 days before the postback is received. If your event processing has a lag longer than 7 days, those events will not appear in Amazon reporting. Immutable guards against this automatically and will not attempt to send events outside this window.
## Token Refresh
Amazon access tokens expire approximately every hour. Immutable automatically refreshes them in the background, so you do not need to reconnect after the initial OAuth flow. No token expiry warning appears on the postback config.
## Verify Delivery
Once an attributed conversion arrives, it appears in the **Delivery logs** section on the Amazon Ads integration page.
For a description of log columns and status values, see [Monitoring Postback Delivery](/docs/products/attribution/postbacks/overview#monitoring-postback-delivery).
## Reconnecting
If your Amazon Ads authorization is revoked, Hub shows a credential warning on the postback config. Open the config menu and click **Reconnect** to re-authorize without losing your Conversion Definition ID mappings.
***
## Next Steps
Define which player actions count as a conversion and control what triggers postbacks
Overview of all supported networks and event mapping
How tracking data powers player attribution and Hub reports
# Google Ads Postbacks
Source: https://docs.immutable.com/docs/products/attribution/postbacks/google-ads
Connect Google Ads via OAuth and map Conversion Goals to import Conversion Actions for server-side conversion tracking
**Who is this for?** Marketers running paid acquisition on Google Ads who want to feed conversion data back to the platform for campaign optimisation and ROAS reporting.
Google Ads postbacks send conversions to Google using offline conversion import. When a player clicks your Google Ads campaign and later completes a [Conversion Goal](/docs/products/attribution/conversion-goals) such as a purchase, Immutable uploads that conversion to Google. Google uses this signal in campaign reports and bid strategies.
## Website events
Do not use Google postbacks for website events such as [Funnel Page](/docs/products/attribution/funnel-page) sign-up. Those fire in the browser via the Google Tag.
| Player action | How Google should receive it |
| --------------------------------------------------------------------------------- | ------------------------------------ |
| Funnel Page sign-up (**Email Captured** in Hub) | Google Tag on the Funnel Page |
| Other Conversion Goals (`purchase`, `wishlist_add`, `game_launch`, custom events) | Google Ads postbacks in Integrations |
Add a **Google Tag ID** on your Funnel Page in Hub so Email Captured is reported by `gtag`. In **Conversion Action Mapping**, Email Captured stays visible but cannot be mapped. Mapping it to a website conversion action fails at upload. Mapping it to an import action would count the same sign-up twice.
Keep the Google Tag on the page for auto-tagging (`gclid`) and Consent Mode. Do not also count Funnel Page sign-up as a postback.
## What You Need
* The Immutable [Web SDK](/docs/products/audience/analytics/web-sdk) installed on your marketing site, so Google click IDs (`gclid`) are captured. A `gclid` is required for every Google postback. Without it, the postback cannot be sent.
* The [Unity SDK](/docs/products/audience/analytics/unity-sdk), [Web SDK](/docs/products/audience/analytics/web-sdk), or [REST API](/docs/products/audience/analytics/rest-api) sending events
* At least one active [Conversion Goal](/docs/products/attribution/conversion-goals) other than Email Captured
* A Google Ads account with at least one **import** Conversion Action (see Step 1)
* Admin access to the game in [Audience Hub](https://hub.immutable.com)
## Step 1: Create Conversion Actions in Google Ads
Create an import Conversion Action for each Goal you want to send via postbacks. Website-tag actions cannot receive these uploads.
1. In [Google Ads](https://ads.google.com), go to **Goals** then **Summary**, and select **+ Create conversion action**.
2. Select **Conversions offline** (CRM, importing a file, or the Google Ads API). Do not select **Conversions on a website**. That creates a Google tag / GA4 action, which postbacks cannot upload to.
3. You can skip connecting a data source. Immutable sends the conversions.
4. Name the action after your Goal (for example, "First Purchase"). Complete setup.
After save, open the action and confirm **Conversion source** is **Website (Import from clicks)**, not **Website**.
The resource name format is: `customers/{customer_id}/conversionActions/{conversion_action_id}`
You can find `{customer_id}` in the top-right of your Google Ads account (without hyphens). You can find `{conversion_action_id}` in the URL when you open a Conversion Action's detail page.
Postbacks only succeed against import / `UPLOAD_CLICKS` actions. A website (`WEBPAGE`) action returns `INVALID_CONVERSION_ACTION_TYPE` and will not deliver.
## Step 2: Connect Google Ads
1. In [Audience Hub](https://hub.immutable.com), navigate to **Settings** then **Integrations** then **Google Ads**.
2. In the **Conversion Postbacks** section, click **Connect**.
3. A Google authorization popup opens. Sign in and grant the requested permissions.
4. Once authorized, Hub shows an account picker. Select the Google Ads account that contains the Conversion Actions you created.
5. Click **Connect**. Hub creates the postback configuration and the popup closes.
## Step 3: Map Goals to Conversion Actions
After connecting, map each Conversion Goal to a Google Ads import Conversion Action. Without a mapping, conversions for that Goal are not sent to Google.
1. On the Google Ads integration page, scroll to **Conversion Action Mapping**.
2. For each Goal, choose a Conversion Action from the dropdown (or paste the resource name if the list cannot load).
3. Click **Save mapping**.
**Email Captured** is listed but greyed out. That conversion is sent by the Funnel Page Google Tag, not by this postback.
Hub only offers Conversion Actions that can receive an import. Website-tag and Google-hosted actions appear disabled, with their type shown.
If the resource name format is invalid, Hub will show a validation error before saving.
## How Google Conversions Work
Google Ads conversion tracking uses a different delivery model than the other networks. Rather than sending each conversion immediately, Immutable stages conversions in a queue and uploads them to Google in batches via the Google Ads API. This is the standard Google Ads offline conversion import pattern.
Every postback requires a `gclid` (Google click ID). If an attributed event does not have a `gclid` in the touchpoint data, Immutable will not send a postback to Google for that event. Make sure your Web SDK is correctly installed on any pages you're running Google Ads campaigns against.
For `purchase` conversions, the conversion `value` and `currency` are included in the upload when both are present, so Google can optimise for revenue (ROAS).
## Consent Mode
Immutable forwards consent signals to Google using Consent Mode v2. For events collected at `full` consent, both `ad_user_data` and `ad_personalization` are set to `GRANTED`. For events at `anonymous` consent, both are set to `UNSPECIFIED`. This lets Google apply appropriate data restrictions without dropping the conversion.
## Verify Delivery
Once conversions start arriving, they appear in the **Delivery logs** section on the Google Ads integration page. Because Google processes conversions in batches, there may be a short delay before they appear in your Google Ads reporting.
For a description of log columns and status values, see [Monitoring Postback Delivery](/docs/products/attribution/postbacks/overview#monitoring-postback-delivery).
## Reconnecting
If your Google authorization expires or is revoked, Hub shows a credential warning on the postback config. Open the config menu and click **Reconnect** to re-authorize. Your Conversion Action mappings are preserved when you reconnect.
***
## Next Steps
Define which player actions count as a conversion and control what triggers postbacks
Hosted landing page for sign-ups. Use a Google Tag here, not Google postbacks.
Overview of all supported networks and event mapping
How tracking data powers player attribution and Hub reports
# Meta Ads Postbacks
Source: https://docs.immutable.com/docs/products/attribution/postbacks/meta-ads
Connect Meta Ads via OAuth to automatically forward attributed conversions to Meta Conversions API
**Who is this for?** Marketers running paid acquisition on Meta Ads (Facebook and Instagram) who want to feed conversion data back to the platform to improve campaign optimisation.
Meta Ads postbacks connect your Immutable game to the Meta Conversions API (CAPI). When a player clicks your Meta Ads campaign, installs the game, and completes a [Conversion Goal](/docs/products/attribution/conversion-goals), Immutable sends a server-to-server postback to Meta automatically.
## What You Need
* The Immutable [Web SDK](/docs/products/audience/analytics/web-sdk) installed on your marketing site, so Meta click IDs (`fbclid`) are captured
* The [Unity SDK](/docs/products/audience/analytics/unity-sdk), [Web SDK](/docs/products/audience/analytics/web-sdk), or [REST API](/docs/products/audience/analytics/rest-api) sending events from your game
* At least one active [Conversion Goal](/docs/products/attribution/conversion-goals) for the game
* A Meta Business account with at least one Meta Pixel in Events Manager
* Admin access to the game in [Audience Hub](https://hub.immutable.com)
## Connect Meta Ads
1. In [Audience Hub](https://hub.immutable.com), navigate to **Settings** then **Integrations** then **Meta Ads**.
2. In the **Conversion Postbacks** section, click **Connect**.
3. A Meta authorization popup opens. Sign in to your Facebook account and grant the requested permissions.
4. Once authorized, Hub shows an account and pixel picker. If your Business Manager has multiple ad accounts or pixels, select the ones you want to send postbacks for.
5. Click **Connect**. Hub creates the postback configuration and the popup closes.
You can only have one Meta Ads postback configuration per game. If you need to switch accounts or pixels, disconnect the existing configuration first, then reconnect with the new selection.
## Event Mapping
Immutable maps Conversion Goal events to Meta standard events as follows:
| Goal event | Meta event |
| ----------------- | ----------------------------------- |
| `purchase` | `Purchase` |
| `wishlist_add` | `AddToWishlist` |
| `wishlist_remove` | `wishlist_remove` (forwarded as-is) |
| `email_acquired` | `CompleteRegistration` |
For `purchase` conversions, Immutable forwards `value` and `currency` when both are present so Meta can optimise for revenue (ROAS) rather than conversion count alone.
Meta has no standard event for `wishlist_remove`. It is forwarded with the raw name `wishlist_remove`, which Meta stores as a custom conversion. You can create a custom conversion in Meta Events Manager to track it.
## Consent and Limited Data Use
Immutable applies Meta's **Limited Data Use (LDU)** flag automatically for events collected at `anonymous` consent level. Under LDU, Meta restricts its use of the data for ad targeting to comply with CCPA and similar regulations. Events at `full` consent are sent without LDU restrictions.
## Test Events
If you want to route test conversions to Meta's Test Events environment rather than live reporting, open the postback config and set a **Test event code** from Meta Events Manager. Test events are only routed this way when the SDK sends them with `test: true`. Normal production conversions are never affected.
## Verify Delivery
Once a real attributed conversion arrives (a player who clicked your Meta Ads campaign and then matched an active Goal), it appears in the **Delivery logs** section on the Meta Ads integration page. You can also check **Meta Events Manager** under your pixel's event activity.
For a description of log columns and status values, see [Monitoring Postback Delivery](/docs/products/attribution/postbacks/overview#monitoring-postback-delivery).
## Reconnecting
If your Meta authorization expires or is revoked, Hub shows a credential warning on the postback config. Open the config menu and click **Reconnect** to re-authorize without losing your existing configuration.
***
## Next Steps
Define which player actions count as a conversion and control what triggers postbacks
Overview of all supported networks and event mapping
How tracking data powers player attribution and Hub reports
# Conversion Postbacks
Source: https://docs.immutable.com/docs/products/attribution/postbacks/overview
Automatically send attributed conversion data back to your ad networks so their algorithms learn which campaigns produce real players
**Who is this for?** Marketers running paid acquisition on TikTok Ads, Meta Ads, Google Ads, Reddit Ads, Amazon Ads (including Twitch, Fire TV, and Amazon DSP), or X Ads who want to feed conversion data back to those platforms to improve campaign optimisation.
When a player clicks your ad, installs the game, and completes an action you've defined as a conversion, [Attribution](/docs/products/attribution/overview) captures the journey. Conversion Postbacks close the loop by notifying the ad network that the click led to a real outcome.
What counts as a conversion is set by your [Conversion Goals](/docs/products/attribution/conversion-goals). A postback fires whenever an attributed event matches an active Goal, so you control exactly which actions are forwarded.
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 Web SDK captures the click ID from your ad campaign on your marketing site.
Your game sends events via the Unity SDK, Web SDK, or REST API as the player plays.
The Audience attribution engine matches the in-game event to the original ad click, then checks it against your active [Conversion Goals](/docs/products/attribution/conversion-goals). If it matches a Goal, the conversion is recorded.
For each matched Goal, Immutable sends a server-to-server callback to your connected ad networks with the conversion data. No code changes needed after initial setup.
## Supported Networks
TikTok Events API, connected via OAuth in Audience Hub
Meta Conversions API, connected via OAuth in Audience Hub
Google Ads API offline conversion import, connected via OAuth in Audience Hub
Reddit Conversions API v3, connected via OAuth in Audience Hub
Amazon Ads Conversions API (covers Twitch, Fire TV, Amazon DSP), connected via OAuth in Audience Hub
X Ads Conversions API, connected via OAuth in Audience Hub
## What You Need
Before configuring postbacks for a game:
* The Immutable [Web SDK](/docs/products/audience/analytics/web-sdk) is installed on your marketing site (captures click IDs from ad platforms)
* The [Unity SDK](/docs/products/audience/analytics/unity-sdk), [Web SDK](/docs/products/audience/analytics/web-sdk), or [REST API](/docs/products/audience/analytics/rest-api) is sending the events your Conversion Goals are built on (such as `purchase`)
* At least one [Conversion Goal](/docs/products/attribution/conversion-goals) is active for the game. A postback fires when an attributed event matches an active Goal.
* You have admin access to the game in [Audience Hub](https://hub.immutable.com)
## Event Mapping
A postback carries the event from the [Conversion Goal](/docs/products/attribution/conversion-goals) that matched. Immutable maps each Goal event to the closest standard event on each network:
| Goal event | TikTok Ads | Meta Ads | Google Ads | Reddit Ads | Amazon Ads | X Ads |
| ----------------- | ---------------------- | ---------------------- | ----------------------------------- | -------------------------------- | --------------------------------------- | ---------------------------------- |
| `purchase` | `Purchase` | `Purchase` | Conversion Action (mapped per Goal) | `Purchase` | Conversion Definition (mapped per Goal) | Single Event Tag (mapped per Goal) |
| `wishlist_add` | `AddToWishlist` | `AddToWishlist` | Conversion Action (mapped per Goal) | `AddToWishlist` | Conversion Definition (mapped per Goal) | Single Event Tag (mapped per Goal) |
| `wishlist_remove` | `wishlist_remove` | `wishlist_remove` | Conversion Action (mapped per Goal) | `Custom` named `wishlist_remove` | Conversion Definition (mapped per Goal) | Single Event Tag (mapped per Goal) |
| `email_acquired` | `CompleteRegistration` | `CompleteRegistration` | Conversion Action (mapped per Goal) | `SignUp` | Conversion Definition (mapped per Goal) | Single Event Tag (mapped per Goal) |
| `game_launch` | `game_launch` (custom) | `game_launch` (custom) | Conversion Action (mapped per Goal) | `Custom` named `game_launch` | Conversion Definition (mapped per Goal) | Single Event Tag (mapped per Goal) |
For `purchase` conversions, Immutable forwards `value` and `currency` when both are present, so ad networks can optimise for revenue (ROAS), not just conversion count. This applies to all six networks.
Google Ads requires you to map each Goal to an import Conversion Action in your Google Ads account. Do not use postbacks for website events such as Funnel Page sign-up (Email Captured). Those are sent by the Google Tag on the page. See the [Google Ads setup guide](/docs/products/attribution/postbacks/google-ads) for details.
Amazon Ads requires you to map each active Goal to a numeric Conversion Definition ID created in your Amazon DSP Console. The event type is set on the definition in Amazon DSP, so Immutable sends only the ID. See the [Amazon Ads setup guide](/docs/products/attribution/postbacks/amazon-ads) for details.
X Ads requires you to map each active Goal to a Single Event Tag (SET) ID created in X Ads Events Manager. The conversion type is set on the tag in X, so Immutable sends only the tag ID. See the [X Ads setup guide](/docs/products/attribution/postbacks/x-ads) for details.
To choose which events count as conversions for your game, set up your [Conversion Goals](/docs/products/attribution/conversion-goals).
***
## Monitoring Postback Delivery
The **Delivery logs** section on each ad network's integration page (**Settings** then **Integrations** then **\[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 Goal event that triggered it, such as `purchase` or `wishlist_add` |
| **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 |
| "Credential failures detected" (warning icon) | Recent deliveries are failing with auth errors. Credentials may have been revoked or expired. |
| "No recent activity" (clock icon) | No delivery history yet. Normal for a newly configured postback. |
***
## FAQ
No. Postbacks rely on click IDs captured by the [Web SDK](/docs/products/audience/analytics/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.
Reddit Ads also accepts a hashed email address as a fallback identifier when no click ID is present. See the [Reddit Ads setup guide](/docs/products/attribution/postbacks/reddit-ads) for details.
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/analytics/rest-api) accepts the same events. Events from any source (Unity SDK, Web SDK, REST API) are eligible to match a [Conversion Goal](/docs/products/attribution/conversion-goals) and 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/analytics/data-dictionary#consent-model) for the full consent model.
Events marked `test: true` (from SDK `testMode`) are handled differently per network. TikTok and Reddit route them to each platform's test environment if a test code or test ID is configured on the postback. Meta uses `test_event_code` in the same way. Normal production events are never affected by test codes, even when one is set.
Erasure removes the user's data from Immutable's systems (see [Deleting User Data](/docs/products/audience/analytics/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 the network directly. New postbacks will not be sent for the erased user.
Check the following:
* Is the postback config **enabled**?
* Is there at least one active [Conversion Goal](/docs/products/attribution/conversion-goals) matching the events you're sending? Without a matching Goal, nothing is forwarded.
* Is the Immutable Web SDK 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. Reconnect the integration for all OAuth networks (TikTok, Meta, Google, Reddit, Amazon, X).
* **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.
## Next Steps
Define which player actions count as a conversion and control what triggers postbacks
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
# Reddit Ads Postbacks
Source: https://docs.immutable.com/docs/products/attribution/postbacks/reddit-ads
Connect Reddit Ads via OAuth to automatically forward attributed conversions to Reddit Conversions API
**Who is this for?** Marketers running paid acquisition on Reddit Ads who want to feed conversion data back to the platform to improve campaign optimisation.
Reddit Ads postbacks connect your Immutable game to the Reddit Conversions API v3. When a player clicks your Reddit Ads campaign, installs the game, and completes a [Conversion Goal](/docs/products/attribution/conversion-goals), Immutable sends a server-to-server postback to Reddit automatically.
## What You Need
* The Immutable [Web SDK](/docs/products/audience/analytics/web-sdk) installed on your marketing site, so Reddit click IDs (`rdt_cid`) are captured. Without a click ID, Reddit requires a hashed email address to match conversions to users (see below).
* The [Unity SDK](/docs/products/audience/analytics/unity-sdk), [Web SDK](/docs/products/audience/analytics/web-sdk), or [REST API](/docs/products/audience/analytics/rest-api) sending events from your game
* At least one active [Conversion Goal](/docs/products/attribution/conversion-goals) for the game
* A Reddit Ads account with at least one pixel in Reddit Ads Manager
* Admin access to the game in [Audience Hub](https://hub.immutable.com)
## Connect Reddit Ads
1. In [Audience Hub](https://hub.immutable.com), navigate to **Settings** then **Integrations** then **Reddit Ads**.
2. In the **Conversion Postbacks** section, click **Connect**.
3. A Reddit authorization popup opens. Sign in to your Reddit Ads account and grant the requested permissions.
4. Once authorized, Hub shows an account and pixel picker. If your Reddit Ads account has multiple pixels, select the one you want to send postbacks for.
5. Click **Connect**. Hub creates the postback configuration and the popup closes.
You can only have one Reddit Ads postback configuration per game. If you need to switch accounts or pixels, disconnect the existing configuration first, then reconnect with the new selection.
## Event Mapping
Immutable maps Conversion Goal events to Reddit standard tracking types as follows:
| Goal event | Reddit type |
| ----------------- | -------------------------------- |
| `purchase` | `Purchase` |
| `wishlist_add` | `AddToWishlist` |
| `wishlist_remove` | `Custom` named `wishlist_remove` |
| `email_acquired` | `SignUp` |
For `purchase` conversions, Immutable forwards `value` and `currency` when both are present so Reddit can optimise for revenue (ROAS) rather than conversion count alone.
Reddit has no standard tracking type for `wishlist_remove`. It is forwarded as a `Custom` event with the name `wishlist_remove`.
## Identifier Requirements
Reddit requires at least one of the following to match a conversion to a user:
* **Click ID**: captured by the Web SDK from a Reddit Ads campaign click
* **Hashed email**: collected by your game via the Unity SDK, Web SDK, or REST API and forwarded by Immutable (SHA-256 hashed)
If neither is present on an attributed event, Immutable will not forward the conversion to Reddit. Make sure your Web SDK is correctly installed on any pages you're running Reddit Ads campaigns against, and that your game collects player email where you need email-based matching.
## Test Events
If you want to route test conversions to Reddit's test environment rather than live reporting, open the postback config and set a **Test ID** from Reddit Ads Manager. Test events are only routed this way when the SDK sends them with `test: true`. Normal production conversions are never affected.
## Verify Delivery
Once a real attributed conversion arrives (a player who clicked your Reddit Ads campaign and then matched an active Goal), it appears in the **Delivery logs** section on the Reddit Ads integration page. You can also check **Reddit Ads Manager** under Events Manager on your pixel's activity page.
For a description of log columns and status values, see [Monitoring Postback Delivery](/docs/products/attribution/postbacks/overview#monitoring-postback-delivery).
## Reconnecting
If your Reddit authorization expires or is revoked, Hub shows a credential warning on the postback config. Open the config menu and click **Reconnect** to re-authorize without losing your existing configuration.
***
## FAQ
Check the following:
* Is the postback config **enabled**?
* Is there at least one active [Conversion Goal](/docs/products/attribution/conversion-goals) matching the events you're sending?
* Is the Web SDK capturing Reddit click IDs on your marketing site?
* Is your game sending the expected events? Without a click ID or hashed email, Reddit postbacks cannot be sent.
Existing token-based configurations keep working and continue forwarding conversions. To move to the OAuth flow, delete the existing configuration and click **Connect** to set it up again.
***
## Next Steps
Define which player actions count as a conversion and control what triggers postbacks
Overview of all supported networks and event mapping
How tracking data powers player attribution and Hub reports
# TikTok Ads Postbacks
Source: https://docs.immutable.com/docs/products/attribution/postbacks/tiktok-ads
Connect TikTok Ads via OAuth to automatically forward attributed conversions to TikTok Events API
**Who is this for?** Marketers running paid acquisition on TikTok Ads who want to feed conversion data back to the platform to improve campaign optimisation.
TikTok Ads postbacks connect your Immutable game to the TikTok Events API. When a player clicks your TikTok Ads campaign, installs the game, and completes a [Conversion Goal](/docs/products/attribution/conversion-goals), Immutable sends a server-to-server postback to TikTok automatically. TikTok's algorithm uses this signal to improve campaign optimisation and ROAS reporting.
## What You Need
* The Immutable [Web SDK](/docs/products/audience/analytics/web-sdk) installed on your marketing site, so `ttclid` values from TikTok Ads clicks are captured
* The [Unity SDK](/docs/products/audience/analytics/unity-sdk), [Web SDK](/docs/products/audience/analytics/web-sdk), or [REST API](/docs/products/audience/analytics/rest-api) sending events from your game
* At least one active [Conversion Goal](/docs/products/attribution/conversion-goals) for the game
* A TikTok Ads account with at least one pixel in TikTok Events Manager
* Admin access to the game in [Audience Hub](https://hub.immutable.com)
## Connect TikTok Ads
1. In [Audience Hub](https://hub.immutable.com), navigate to **Settings** then **Integrations** then **TikTok Ads**.
2. In the **Conversion Postbacks** section, click **Connect**.
3. A TikTok authorization popup opens. Sign in to your TikTok Business account and grant the requested permissions.
4. Once authorized, Hub shows an account and pixel picker if your account has multiple options. Select the ad account and pixel you want to send postbacks for.
5. Click **Connect**. Hub creates the postback configuration and the popup closes.
If you manage multiple TikTok ad accounts or pixels, make sure you select the one tied to the campaigns you're running against this game. You can only have one TikTok Ads postback configuration per game.
## Event Mapping
Immutable maps Conversion Goal events to TikTok standard events as follows:
| Goal event | TikTok event |
| ----------------- | ----------------------------------- |
| `purchase` | `Purchase` |
| `wishlist_add` | `AddToWishlist` |
| `wishlist_remove` | `wishlist_remove` (forwarded as-is) |
| `email_acquired` | `CompleteRegistration` |
For `purchase` conversions, Immutable forwards `value` and `currency` when both are present so TikTok can optimise for revenue (ROAS) rather than conversion count alone.
TikTok has no standard event for `wishlist_remove`. It is forwarded as a custom event with the raw name `wishlist_remove`. You can create a custom conversion in TikTok Events Manager to track it.
## Test Events
If you want to route test conversions to TikTok's Test Events environment rather than live reporting, open the postback config and set a **Test event code** from TikTok Events Manager. Test events are only routed this way when the SDK sends them with `test: true`. Normal production conversions are never affected by the test code.
## Verify Delivery
Once a real attributed conversion arrives (a player who clicked your TikTok Ads campaign and then matched an active Goal), it appears in the **Delivery logs** section on the TikTok Ads integration page. You can also check **TikTok Events Manager** under your pixel's event activity.
For a description of log columns and status values, see [Monitoring Postback Delivery](/docs/products/attribution/postbacks/overview#monitoring-postback-delivery).
## Reconnecting
If your TikTok authorization expires or is revoked, Hub shows a credential warning on the postback config. Open the config menu and click **Reconnect** to re-authorize without losing your existing configuration.
***
## Next Steps
Define which player actions count as a conversion and control what triggers postbacks
Overview of all supported networks and event mapping
How tracking data powers player attribution and Hub reports
# X Ads Postbacks
Source: https://docs.immutable.com/docs/products/attribution/postbacks/x-ads
Connect X Ads via OAuth to automatically forward attributed conversions to the X Ads Conversions API
**Who is this for?** Marketers running paid acquisition on X Ads (formerly Twitter Ads) who want to feed conversion data back to the platform to improve campaign optimisation.
X Ads postbacks connect your Immutable game to the X Ads Conversions API. When a player clicks your X Ads campaign and completes a [Conversion Goal](/docs/products/attribution/conversion-goals), Immutable sends a server-to-server postback to X automatically.
## What You Need
* The Immutable [Web SDK](/docs/products/audience/analytics/web-sdk) installed on your marketing site, so X click IDs (`twclid`) are captured. Without a click ID, X requires a hashed email address to match conversions to users (see [Identifier Requirements](#identifier-requirements)).
* The [Unity SDK](/docs/products/audience/analytics/unity-sdk), [Web SDK](/docs/products/audience/analytics/web-sdk), or [REST API](/docs/products/audience/analytics/rest-api) sending events from your game
* At least one active [Conversion Goal](/docs/products/attribution/conversion-goals) for the game
* An X Ads account with a pixel and at least one Single Event Tag created in [X Ads Events Manager](https://ads.x.com/) (see [Event Mapping](#event-mapping))
* Admin access to the game in [Audience Hub](https://hub.immutable.com)
## Connect X Ads
1. In [Audience Hub](https://hub.immutable.com), navigate to **Settings** then **Integrations** then **X Ads**.
2. In the **Conversion Postbacks** section, click **Connect**.
3. An X authorization popup opens. Sign in to your X Ads account and grant the requested permissions.
4. After authorizing, Hub asks for your **X Pixel ID**. X has no pixel discovery API, so paste the Pixel ID (the Universal Website Tag ID) from your [X Ads Events Manager](https://ads.x.com/).
5. Click **Connect**. Hub creates the postback configuration and the popup closes.
You can only have one X Ads postback configuration per game. If you need to switch accounts, disconnect the existing configuration first, then reconnect with the new account.
## Event Mapping
X has no API to discover conversions, so you create a Single Event Tag (SET) for each conversion in your [X Ads Events Manager](https://ads.x.com/), then map each active [Conversion Goal](/docs/products/attribution/conversion-goals) to its SET tag ID in Hub:
1. On the X Ads integration page, scroll to the **Event Tag Mapping** section.
2. For each active Goal, paste the SET tag ID (it looks like `tw-o5h34-o9p5x`).
3. Click **Save**.
Goals left unmapped are not forwarded to X.
The conversion type (such as Purchase, Add To Wishlist, or Lead) is set on the SET tag when you create it in X Ads Events Manager, so Immutable sends only the tag ID and X resolves the conversion against the tag's settings. Because the mapping is per-Goal, two Goals that share the same event can report to different SET tags.
For `purchase` conversions, Immutable forwards `value` and `currency` when both are present, so X can optimise for revenue (ROAS) rather than conversion count alone.
## Identifier Requirements
X requires at least one of the following to match a conversion to a user:
* **Click ID (`twclid`)**: captured by the Web SDK from an X Ads campaign click
* **Hashed email**: collected by your game via the Unity SDK, Web SDK, or REST API and forwarded by Immutable (SHA-256 hashed)
If neither is present on an attributed event, Immutable will not forward the conversion to X. Make sure your Web SDK is correctly installed on any pages you're running X Ads campaigns against, and that your game collects player email where you need email-based matching.
When a player is at `full` consent, Immutable also forwards IP address and user agent to improve match quality. These are match-quality boosters only and cannot satisfy the minimum identifier requirement on their own.
## Token Behavior
Unlike other networks, X access tokens do not expire. Once you connect, the authorization remains valid until you or the studio explicitly revoke it in your X account. There is no background token refresh and no periodic reconnect, so the connection keeps working indefinitely.
## Verify Delivery
Once a real attributed conversion arrives (a player who clicked your X Ads campaign and then matched an active Goal), it appears in the **Delivery logs** section on the X Ads integration page. You can also check **X Ads Events Manager** for activity on your pixel.
For a description of log columns and status values, see [Monitoring Postback Delivery](/docs/products/attribution/postbacks/overview#monitoring-postback-delivery).
X can reject individual conversions while still returning an overall success response. When this happens, the affected event is recorded as **Failed** in the delivery log, typically because the SET tag ID is wrong or the identifiers didn't match. These are not retried.
## Reconnecting
If your X authorization is revoked, Hub shows a credential warning on the postback config. Open the config menu and click **Reconnect** to re-authorize without losing your existing configuration.
***
## FAQ
Check the following:
* Is the postback config **enabled**?
* Is there at least one active [Conversion Goal](/docs/products/attribution/conversion-goals) matching the events you're sending?
* Is the Web SDK capturing X click IDs (`twclid`) on your marketing site?
* Have you created a Single Event Tag in X Ads Events Manager and mapped each Goal to its tag ID?
* Is your game sending the expected events? Without a click ID or hashed email, X postbacks cannot be sent.
No. X access tokens do not expire, so the connection stays valid until you revoke it. You only need to use **Reconnect** if you revoke access on the X side or Hub shows a credential warning.
***
## Next Steps
Define which player actions count as a conversion and control what triggers postbacks
Overview of all supported networks and event mapping
How tracking data powers player attribution and Hub reports
# Data Dictionary
Source: https://docs.immutable.com/docs/products/audience/analytics/data-dictionary
Complete reference of data collected and event schemas across the Web SDK, Unity SDK, and REST API
The 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 [Web SDK](/docs/products/audience/analytics/web-sdk), [Unity SDK](/docs/products/audience/analytics/unity-sdk), and [REST API](/docs/products/audience/analytics/rest-api), and the data forwarded externally by [Conversion Postbacks](/docs/products/attribution/postbacks/overview). 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 Web SDK >}>
Persistent anonymous device identifier (UUID v4).
| Property | Value |
| ---------- | --------------------------------- |
| Lifetime | 2 years |
| Scope | First-party, current hostname |
| Attributes | `SameSite=Lax`, `Secure` on HTTPS |
_imtbl_sid Web 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 Web SDK >}>
Google Analytics client ID. Read-only. The Web SDK reads this cookie for cross-platform identity stitching but does not write it.
| Property | Value |
| -------- | ---------------- |
| Source | Google Analytics |
_fbc Web SDK >}>
Facebook click ID. Read-only. The Web SDK reads this cookie for cross-platform identity stitching but does not write it.
| Property | Value |
| -------- | --------------- |
| Source | Meta (Facebook) |
_fbp Web SDK >}>
Facebook browser ID. Read-only. The Web SDK reads this cookie for cross-platform identity stitching but does not write it.
| Property | Value |
| -------- | --------------- |
| Source | Meta (Facebook) |
## Auto-Tracked
Signals collected and events fired by the integration without any studio code.
### Device Fingerprint Signals
These signals are collected automatically when consent is `anonymous` or `full`.
| 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 Web SDK collects these URL-based signals. Mobile Unity builds with attribution enabled collect a separate set of device-level signals. Desktop Unity builds collect none.
The Web SDK attaches them to 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` |
| Reddit click ID | URL query string | `rdt_cid` |
| 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` |
| X click ID | URL query string | `twclid` |
| 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 `distribution_platform` property on [`game_launch`](#auto-tracked-events).
Mobile [attribution builds](/docs/products/audience/analytics/unity-sdk#mobile) (`AUDIENCE_MOBILE_ATTRIBUTION` + `EnableMobileAttribution = true`) ship additional signals with `game_launch`.
**iOS**
| Property | Type | Consent | Description |
| ----------------- | --------- | ------------ | -------------------------------------------------------------------------------------------------------- |
| `att_status` | `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`. |
| `skan_registered` | `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 `gaid_limit_ad_tracking` is `true`. Available from the second launch onwards. |
| `gaid_limit_ad_tracking` | `boolean` | `Anonymous`+ | Whether the user has opted out of ad personalization. Available from the second launch onwards. |
### 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'`. |
| `is_editor` | `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 |
| `build_guid` | `string` | No | Unity build GUID |
| `unity_version` | `string` | No | Unity version the build was made with |
| `os_family` | `string` | No | Host operating system family from Unity's `SystemInfo.operatingSystemFamily`. One of `'Windows'`, `'MacOSX'`, `'Linux'`, `'Other'`. |
| `device_model` | `string` | No | Hardware model |
| `gpu` | `string` | No | GPU name |
| `gpu_vendor` | `string` | No | GPU vendor |
| `cpu` | `string` | No | CPU name |
| `cpu_cores` | `integer` | No | Number of CPU cores |
| `ram_mb` | `integer` | No | System RAM in megabytes |
| `screen_dpi` | `integer` | No | Display DPI (omitted when 0). |
| `distribution_platform` | `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. |
| `android_id` | `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/attribution/postbacks/overview) 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",
"is_editor": false,
"version": "1.2.0",
"build_guid": "00000000-0000-0000-0000-000000000000",
"unity_version": "2022.3.10f1",
"os_family": "Windows",
"device_model": "PC",
"gpu": "Generic GPU",
"gpu_vendor": "Generic",
"cpu": "Generic CPU",
"cpu_cores": 8,
"ram_mb": 16384,
"screen_dpi": 96,
"distribution_platform": "steam"
}
}
```
session_start Unity SDK >}>
Fires when a new session begins. The Unity SDK fires it on `Init`, consent upgrade from `None`, or resume after a pause longer than 30 seconds. The Web SDK does not emit this event; it attaches a `sessionId` to every message instead (see [Auto-attached metadata](#auto-attached-metadata)) and session boundaries are derived from that.
| Property | Type | Description |
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `session_id` | `string` | Unity SDK only, deprecated. Use the top-level `sessionId` field instead, see [Auto-attached metadata](#auto-attached-metadata). |
session_end Unity SDK >}>
Fires when a session ends. Only fires when consent is at `Anonymous` or higher. The Unity SDK fires it when `Shutdown` is called, consent is downgraded to `None`, or a session rolls due to extended pause. The Web SDK does not emit this event.
| Property | Type | Description |
| -------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `session_id` | `string` | Deprecated. Use the top-level `sessionId` field instead, see [Auto-attached metadata](#auto-attached-metadata). |
| `duration_sec` | `number` | Engagement seconds since `session_start`, wall-clock minus accumulated pause time |
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 |
| -------------- | -------- | --------------------------------------------------------------------------------------------------------------- |
| `session_id` | `string` | Deprecated. Use the top-level `sessionId` field instead, see [Auto-attached metadata](#auto-attached-metadata). |
| `duration_sec` | `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 does 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 |
| ----------------- | -------- | ------------ | -------------------------------------------------------------------------- |
| `previous_status` | `string` | `Anonymous`+ | Prior ATT status: `notDetermined`, `restricted`, `denied`, or `authorized` |
| `new_status` | `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 |
| ------------------ | -------- | --------------------------------------------------------------------------------------- |
| `install_referrer` | `string` | Raw referrer string from Google Play (e.g. `utm_source=google-play&utm_medium=organic`) |
page Web SDK >}>
Call `page()` manually on each route change.
| Property | Type | Web SDK | Description |
| ----------------- | -------- | ---------------- | ------------------------------------------------------- |
| `utm_source` | `string` | Once per session | UTM source parameter from URL |
| `utm_medium` | `string` | Once per session | UTM medium parameter from URL |
| `utm_campaign` | `string` | Once per session | UTM campaign parameter from URL |
| `utm_term` | `string` | Once per session | UTM term parameter from URL |
| `utm_content` | `string` | Once per session | UTM content parameter from URL |
| `gclid` | `string` | Once per session | Google Ads click ID |
| `fbclid` | `string` | Once per session | Meta click ID |
| `ttclid` | `string` | Once per session | TikTok click ID |
| `rdt_cid` | `string` | Once per session | Reddit click ID |
| `msclkid` | `string` | Once per session | Microsoft click ID |
| `dclid` | `string` | Once per session | Display & Video 360 click ID |
| `li_fat_id` | `string` | Once per session | LinkedIn click ID |
| `twclid` | `string` | Once per session | X click ID |
| `referrer` | `string` | Once per session | Referring page URL |
| `landing_page` | `string` | Once per session | Entry point URL |
| `referral_code` | `string` | Once per session | Custom referral link parameter |
| `touchpoint_type` | `string` | Once per session | `'click'` when any click ID or UTM parameter is present |
| `ga_client_id` | `string` | Every page | Google Analytics client ID (from `_ga` cookie) |
| `fb_click_id` | `string` | Every page | Facebook click ID (from `_fbc` cookie) |
| `fb_browser_id` | `string` | Every page | Facebook browser ID (from `_fbp` cookie) |
form_submitted Web SDK >}>
Fires on HTML form submission. Can be disabled with `autocapture.forms: false`.
| Property | Type | Description |
| ------------- | ---------- | -------------------------------------------------- |
| `form_action` | `string` | Form action URL |
| `form_id` | `string` | Form element ID |
| `form_name` | `string` | Form element name |
| `field_names` | `string[]` | Names of form fields |
| `email_hash` | `string` | SHA-256 hashed email address (`full` consent only) |
scroll_depth Web SDK >}>
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) |
## 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 |
| ----------- | ---------------------------------------------------------------------- |
| Web SDK | Reads or sets the `imtbl_anon_id` first-party cookie |
| Unity SDK | Generates a persistent GUID stored in `Application.persistentDataPath` |
| REST API | Provided by the caller in the message payload |
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 an email account.
For exact method signatures and the full list of `IdentityType` values, see the API Reference section of the [Web SDK](/docs/products/audience/analytics/web-sdk#api-reference), [Unity SDK](/docs/products/audience/analytics/unity-sdk#api-reference), or [REST API](/docs/products/audience/analytics/rest-api#api-reference) docs.
## Predefined Events
Typed events for common player actions. These schemas apply to the [Web SDK](/docs/products/audience/analytics/web-sdk), the [Unity SDK](/docs/products/audience/analytics/unity-sdk), and the [REST API](/docs/products/audience/analytics/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 SDK REST 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 SDK REST 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 SDK Unity SDK REST API >}>
Player completed a purchase.
**Event name:** `purchase`
| Property | Type | Required | Description |
| ---------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `currency` | `string` | **Yes** | Currency code (e.g. `'USD'`, `'ETH'`) |
| `value` | `string` | **Yes** | Total purchase value. A numeric-looking string (e.g. `'9.99'`), not a number, to avoid floating-point precision loss. Unity SDK still sends a `decimal` pending a follow-up update. |
| `item_id` | `string` | No | Unique identifier for the item |
| `item_name` | `string` | No | Display name of the item |
| `quantity` | `number` | No | Number of items purchased |
| `transaction_id` | `string` | No | Your internal transaction reference |
**Postback forwarding:** when [Conversion Postbacks](/docs/products/attribution/postbacks/overview) 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',
item_name: '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",
"item_name": "Legendary Sword",
"quantity": 1
}
}
```
progression Web SDK Unity SDK REST 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 |
| `duration_sec` | `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,
duration_sec: 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,
"duration_sec": 120
}
}
```
resource Web SDK Unity SDK REST 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 |
| `item_type` | `string` | No | Category of the item involved (e.g. `'weapon'`, `'consumable'`) |
| `item_id` | `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,
item_type: 'weapon',
item_id: '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,
"item_type": "weapon",
"item_id": "legendary-sword"
}
}
```
achievement_unlocked Web SDK Unity SDK REST API >}>
Player unlocked an achievement.
**Event name:** `achievement_unlocked`
| Property | Type | Required | Description |
| ------------------ | ------------------------------------------------------------------------ | -------- | ------------------------------------- |
| `achievement_id` | `string` | **Yes** | Unique identifier for the achievement |
| `achievement_name` | `string` | **Yes** | Display name of the achievement |
| `achievement_type` | `'onboarding' \| 'progression' \| 'mastery' \| 'social' \| 'collection'` | No | Category of the achievement |
Player defeats 100 enemies and unlocks a mastery achievement:
```typescript theme={null}
audience.track(AudienceEvents.ACHIEVEMENT_UNLOCKED, {
achievement_id: 'ach_enemies_100',
achievement_name: '100 Enemies Defeated',
achievement_type: 'mastery',
});
```
```csharp theme={null}
ImmutableAudience.Track(new AchievementUnlocked
{
AchievementId = "ach_enemies_100",
AchievementName = "100 Enemies Defeated",
AchievementType = AchievementType.Mastery,
});
```
```json theme={null}
{
"type": "track",
"eventName": "achievement_unlocked",
"properties": {
"achievement_id": "ach_enemies_100",
"achievement_name": "100 Enemies Defeated",
"achievement_type": "mastery"
}
}
```
wishlist_add / wishlist_remove Web SDK REST API >}>
Player added or removed a game from their wishlist.
**Event names:** `wishlist_add`, `wishlist_remove`
**wishlist\_add:**
| Property | Type | Required | Description |
| ---------- | -------- | -------- | -------------------------------------------------------------------------- |
| `game_id` | `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, {
game_id: 'game-123',
source: 'store',
platform: 'steam',
});
```
```json theme={null}
{
"type": "track",
"eventName": "wishlist_add",
"properties": { "game_id": "game-123", "source": "store", "platform": "steam" }
}
```
**wishlist\_remove:**
| Property | Type | Required | Description |
| --------- | -------- | -------- | ------------------------------ |
| `game_id` | `string` | **Yes** | Unique identifier for the game |
```typescript theme={null}
audience.track(AudienceEvents.WISHLIST_REMOVE, { game_id: 'game-123' });
```
```json theme={null}
{
"type": "track",
"eventName": "wishlist_remove",
"properties": { "game_id": "game-123" }
}
```
email_acquired Web SDK REST 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 SDK REST API >}>
Player viewed a game page.
**Event name:** `game_page_viewed`
| Property | Type | Required | Description |
| ----------- | -------- | -------- | ------------------------------------------ |
| `game_id` | `string` | **Yes** | Unique identifier for the game |
| `game_name` | `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, {
game_id: 'game-123',
game_name: 'My Game',
slug: 'my-game',
});
```
```json theme={null}
{
"type": "track",
"eventName": "game_page_viewed",
"properties": { "game_id": "game-123", "game_name": "My Game", "slug": "my-game" }
}
```
link_clicked Web SDK REST API >}>
Player clicked a link. Fires automatically for outbound link clicks (external domains only) via auto-capture — disable with `autocapture.clicks: false` — or manually via `track()`/the REST API for links auto-capture can't see (e.g. non-anchor elements, server-rendered redirects).
**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'`) |
| `element_id` | `string` | No | DOM id of the clicked element. Only set by auto-capture |
| `outbound` | `boolean` | No | Whether the link led off-site. Only set by auto-capture |
Auto-captured clicks also carry the session's attribution signals (`utm_*`, click IDs, `referrer`, `landing_page`, `referral_code`, `touchpoint_type`).
```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 REST API >}>
Player reached a named milestone or achievement. Use for any named accomplishment that does not fit the start/complete/fail shape of `progression`. Covers both **one-shot** achievements (e.g. `first_boss_defeated`, `tutorial_complete`, `100_percent_completion`) and **recurring** named milestones (e.g. `character__level_`, `prestige_tier_`, `streak_day_`, `pvp_rank_`). Parameterise `name` for the recurring case so each instance is distinguishable in analytics.
**Event name:** `milestone_reached`
| Property | Type | Required | Description |
| -------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | **Yes** | Milestone identifier. One-shot example: `'first_boss_defeated'`. Recurring example: `'character_aria_level_5'` |
Player defeats the first boss:
```csharp theme={null}
ImmutableAudience.Track(new MilestoneReached { Name = "first_boss_defeated" });
```
```json theme={null}
{
"type": "track",
"eventName": "milestone_reached",
"properties": { "name": "first_boss_defeated" }
}
```
The Web SDK does not yet ship a typed equivalent. Studios on that surface 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, including `identify()`/`Identify()` and `alias()`/`Alias()` calls:
* A persistent anonymous device identifier (`anonymousId`)
* The current session identifier (`sessionId`)
* 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 |
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `track('sign_up', ...)`, `track('link_clicked', ...)` | [Attribution signals](#attribution-signals) from the session's first-touch snapshot |
| every `track()` call after `identify()` (Full consent only) | `userId` |
Attribution is the session's first-touch snapshot, captured at session start. A player who arrives via `?utm_source=discord`, navigates to another page, then triggers `sign_up` still carries `utm_source=discord` on that event, and `landing_page` is included.
| 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 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 | None | `imtbl_anon_id`, `_imtbl_sid` | `imtbl_anon_id`, `_imtbl_sid` |
| Device fingerprint | No | Yes | Yes |
| Attribution signals | No | Yes | Yes |
| `page()` | No | Yes | Yes |
| `track()` | 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 |
| `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/analytics/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/attribution/postbacks/overview) 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, `rdt_cid` for Reddit Ads) | Captured by the 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, 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/attribution/postbacks/overview) 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)
## Next Steps
How tracking data powers player attribution and Hub reports
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
# Analytics
Source: https://docs.immutable.com/docs/products/audience/analytics/overview
Understand player quality, funnels, and cohorts from Audience CDP profiles
Analytics is the **understand** surface of Audience CDP: who your players are, how good the traffic is, and which cohorts to message. It reads profiles filled by [Ingestion](/docs/products/audience/ingestion).
For **which campaign produced them**, use [Attribution](/docs/products/attribution/overview).
## What you can answer
| Question | Analytics |
| -------------------------- | ----------------------------------------------------- |
| Are signups real players? | Quality and bot / farmer signals |
| Who behaves similarly? | Segments and cohorts |
| Where do players drop off? | Funnels from first touch through retention |
| Who should Engage message? | Segments for [Engage](/docs/products/audience/engage) |
## In Hub
| View | Use it for |
| --------------- | ----------------------------------------------- |
| **Overview** | Volume, conversion, engagement trends |
| **Sources** | Acquisition mix and traffic quality |
| **Segments** | Behavior and value cohorts |
| **Predictions** | Launch and conversion likelihood (when enabled) |
Access depends on your Audience plan. Contact us if Insights is not enabled.
## Keep data healthy
1. Ingest from site, game, Funnel Page, or CSV ([Ingestion](/docs/products/audience/ingestion)).
2. Follow the [Data Dictionary](/docs/products/audience/analytics/data-dictionary).
3. Identify players after login so anonymous history joins known profiles.
## Analytics vs Attribution
| | **Analytics** | **Attribution** |
| -------- | ------------------------------------ | ------------------------------------- |
| Question | Who are they and how do they behave? | Which channel or campaign drove them? |
| Output | Quality, segments, funnels | Source credit, goals, postbacks |
## Next steps
Message the segments you find
Improve what Analytics can see
Optimize spend against the same players
Event schemas
# REST API
Source: https://docs.immutable.com/docs/products/audience/analytics/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 Audience analytics pipeline over HTTP. It uses the same event schemas as the [Web SDK](/docs/products/audience/analytics/web-sdk) and powers [Attribution](/docs/products/attribution/overview) measurement. 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 | [Web SDK](/docs/products/audience/analytics/web-sdk) |
| Instrument a web app with typed, explicit events | [Web SDK](/docs/products/audience/analytics/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
* A publishable key from your project settings ([API keys guide](/docs/guides/advanced-setup/api-keys))
## Quick Start
Replace `YOUR_SYSTEM_NAME` with your own backend or system name (e.g. `"my-game-server"`, `"studio-backend"`).
Avoid these names, which belong to Immutable products and will mix your events with theirs in analytics:
* `"@imtbl/audience"` (Web SDK)
* `"com.immutable.audience"` (Unity SDK)
```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",
"anonymousId": "anon-device-abc123",
"surface": "web",
"properties": {
"currency": "USD",
"value": "9.99",
"item_id": "sword_01"
},
"context": {
"library": "YOUR_SYSTEM_NAME",
"libraryVersion": "YOUR_VERSION"
}
}]
}'
```
**Response:**
```json theme={null}
{
"success": true,
"accepted": 1,
"rejected": 0,
"rejections": []
}
```
## Authentication
Sending events, recording consent, and reading consent all require a publishable key in the `x-immutable-publishable-key` header. [Deleting user data](#deleting-user-data) is the exception: since it's a destructive, per-user operation, it requires your **secret** API key in the `x-immutable-api-key` header instead (a publishable key is public and can't prove ownership of the data being erased).
The base URL is `https://api.immutable.com`. Manage your keys in [Immutable Hub](https://hub.immutable.com). See the [API Keys guide](/docs/guides/advanced-setup/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 Web SDK. See the [Data Dictionary → Consent Model](/docs/products/audience/analytics/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 own backend with the user's `anonymousId` **or** `userId`, not both. Unlike every other endpoint on this page, this call requires your **secret** API key, not your publishable key: the publishable key identifies a project, not a user, so it can't prove the caller is authorized to erase a specific player's data. This also means the call must originate from server-side code, never a browser or game client.
Never expose your secret API key client-side. Keep it in a server environment variable and make this call from your backend only.
```bash theme={null}
# By anonymous ID
curl -X DELETE "https://api.immutable.com/v1/audience/data?anonymousId=anon-device-abc123" \
-H "x-immutable-api-key: YOUR_SECRET_KEY"
# By user ID
curl -X DELETE "https://api.immutable.com/v1/audience/data?userId=user-123" \
-H "x-immutable-api-key: YOUR_SECRET_KEY"
```
A concrete server-side example (Node.js):
```typescript theme={null}
// server-side only, never ship this key to a client
const response = await fetch(
`https://api.immutable.com/v1/audience/data?userId=${encodeURIComponent(userId)}`,
{
method: 'DELETE',
headers: { 'x-immutable-api-key': process.env.IMX_SECRET_KEY! },
},
);
```
Response: `202 Accepted`
| Field | Where | Required | Description |
| --------------------- | ------------ | -------- | ---------------------------------------------- |
| `x-immutable-api-key` | Header | Yes | Your secret API key (not 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 | Auth |
| ------ | ---------------------------------------------- | ----------------------------------- | --------------- |
| POST | `/v1/audience/messages` | Ingest up to 100 events per request | Publishable key |
| GET | `/v1/audience/tracking-consent?anonymousId=` | Read stored consent status | Publishable key |
| PUT | `/v1/audience/tracking-consent` | Record a consent decision | Publishable key |
| DELETE | `/v1/audience/data?anonymousId=` or `?userId=` | GDPR erasure request | Secret key |
### 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": 1,
"rejected": 1,
"rejections": [
{
"messageId": "550e8400-e29b-41d4-a716-446655440002",
"errors": [
{
"field": "context.library",
"code": "MISSING_REQUIRED_FIELD",
"message": "context.library is required"
}
]
}
]
}
```
The endpoint returns `200 OK` even if some messages in the batch fail validation: check `rejected` and `rejections` to see what went wrong. Each `rejections` entry is keyed by `messageId` and lists every violation found for that message. See [Error Responses](#error-responses) below for what `400` means and the full list of rejection codes.
### 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 or integration name, e.g. `"my-game-server"`) and `libraryVersion` (your version). Use your own name, not one claimed by an Immutable product. |
| `userId` | string | — | Your canonical user ID. Send once the user is identified (requires `consentLevel: full`). |
| `anonymousId` | string | Yes | Anonymous device or session ID. Required on every message type, even alongside `userId`. |
| `surface` | string | — | `web`, `unity`, or `unreal` |
| `consentLevel` | string | — | `none`, `anonymous`, or `full`. If set, must be `full` when `userId` is also present, otherwise the message is rejected. If omitted, the level is inferred server-side (`full` when `userId` is set, `anonymous` otherwise) rather than validated against it. |
| `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 `anonymousId`
```json theme={null}
{
"type": "track",
"messageId": "550e8400-e29b-41d4-a716-446655440001",
"eventTimestamp": "2026-04-08T12:00:00Z",
"eventName": "purchase",
"userId": "user-123",
"anonymousId": "anon-device-abc123",
"surface": "web",
"properties": { "currency": "USD", "value": "9.99", "item_id": "sword_01" },
"context": { "library": "my-studio-backend", "libraryVersion": "1.0.0" }
}
```
Use any `eventName`. [Predefined events](/docs/products/audience/analytics/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:** `anonymousId`
```json theme={null}
{
"type": "identify",
"messageId": "550e8400-e29b-41d4-a716-446655440004",
"eventTimestamp": "2026-04-08T12:00:00Z",
"userId": "user-123",
"anonymousId": "anon-device-abc123",
"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 an email account.
**Also required:** `anonymousId`, `fromId`, `toId` (must be different). `from` is the account being linked. `to` is the player's canonical account. `anonymousId` isn't used for the merge (`fromId`/`toId` are); it's required so alias messages have the same shape as every other event, keeping queries and log correlation simple.
```json theme={null}
{
"type": "alias",
"messageId": "550e8400-e29b-41d4-a716-446655440005",
"eventTimestamp": "2026-04-08T12:00:00Z",
"anonymousId": "anon-device-abc123",
"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:** `anonymousId`
```json theme={null}
{
"type": "page",
"messageId": "550e8400-e29b-41d4-a716-446655440006",
"eventTimestamp": "2026-04-08T12:00:00Z",
"userId": "user-123",
"anonymousId": "anon-device-abc123",
"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`.
**Also required:** `anonymousId`
```json theme={null}
{
"type": "screen",
"messageId": "550e8400-e29b-41d4-a716-446655440007",
"eventTimestamp": "2026-04-08T12:00:00Z",
"userId": "user-123",
"anonymousId": "anon-device-abc123",
"properties": { "screen_name": "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/analytics/data-dictionary#predefined-events).
### Error Responses
| Status | Meaning |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200` | Request accepted. Check `accepted`, `rejected`, and `rejections` for per-message detail. |
| `400` | Malformed request (missing header, empty/oversized batch), or the entire batch failed validation, in which case the response has the same shape as `200` with `rejections` populated. |
| `401` | API key not recognised, or the wrong key type for this endpoint (e.g. a publishable key sent to `DELETE /v1/audience/data`, which requires a secret key). Check your key is correct. |
| `500` | Server error. Retry with exponential back-off. |
**Rejection codes:**
| Code | Typical cause |
| ------------------------ | ----------------------------------------------------------------------------------------- |
| `MISSING_REQUIRED_FIELD` | A required field for that message type is missing, e.g. `eventName` on a `track` message. |
| `INVALID_FORMAT` | `messageId` isn't a valid UUID, or `eventTimestamp` isn't valid RFC 3339. |
| `INVALID_VALUE` | A field's value breaks a rule, e.g. `eventTimestamp` is too far in the past or future. |
| `INVALID_ENUM` | A field isn't one of its allowed values, e.g. `surface` or `consentLevel`. |
| `UNSUPPORTED_TYPE` | `type` isn't a supported message type. |
| `DUPLICATE_MESSAGE_ID` | The same `messageId` appears more than once in the batch. |
Treat `code` as an open set rather than a fixed enum: new codes may be added over time, so match on the ones you handle and fall back to `message` for anything else.
## 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 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/analytics/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 (64-bit), macOS, Linux (64-bit), 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 for Audience analytics and [Attribution](/docs/products/attribution/overview). It connects campaigns and referral links to 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
* A publishable key from your project settings ([API keys guide](/docs/guides/advanced-setup/api-keys))
- A target build platform. Tested on:
* **Windows 10+ (64-bit)**
* **macOS Sequoia 15.7.4+**
* **Linux Ubuntu 22.04 LTS+ (64-bit)**
* **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#audience/v0.7.3
```
The package appears in your project as **Immutable Audience** under **Packages** in the Project window.
To update to a newer release, edit the version tag 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/analytics/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/analytics/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();
}
}
```
## Auto-Tracked Events
The SDK fires `game_launch`, `session_start`, `session_end`, and `session_heartbeat` automatically, without any `Track` calls. On mobile with attribution enabled (see [Mobile](#mobile)), it also fires `tracking_authorization_changed` and `install_referrer_received`.
For the full property set on each, see [Auto-Tracked Events](/docs/products/audience/analytics/data-dictionary#auto-tracked-events) in the Data Dictionary.
## 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/analytics/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` | Tick **Enable for iOS** / **Enable for Android** on the Mobile Build Settings asset (see [Platform setup](#platform-setup)). Set separately per platform. |
| **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 read three things off this asset: whether attribution is enabled for iOS, the `NSUserTrackingUsageDescription` string (required, Apple rejects builds that omit it), and the SKAdNetwork IDs you want install attribution from (optional, but empty means no SKAdNetwork data ships). Create the asset once per Unity project:
1. **Assets → Create → Immutable Audience → Mobile Build Settings**
2. Tick **Enable for iOS** at the top of the asset. This writes the `AUDIENCE_MOBILE_ATTRIBUTION` define for you.
3. Set **Tracking Usage Description** to a specific description of what you collect and why. Apple rejects generic copy.
4. Add each ad network you run install campaigns through to **SKAdNetwork IDs**. Get each ID from that network's own SDK docs, or from the [IAB Tech Lab list](https://iabtechlab.com/software/skadnetwork-id-list/).
**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/analytics/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.
Tick **Enable for Android** on the Mobile Build Settings asset first (see the iOS tab for where to create it, it's the same asset for both platforms).
Android attribution uses two Google libraries when `AUDIENCE_MOBILE_ATTRIBUTION` is set. The SDK resolves them from Maven rather than bundling them as `.aar` files, because a bundled AAR can't be de-duplicated by Gradle and conflicts with any other SDK that pulls the same library.
| Dependency | Powers |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `com.android.installreferrer:installreferrer:2.2` | [Play Install Referrer](https://developer.android.com/google/play/installreferrer): the [`install_referrer_received`](/docs/products/audience/analytics/data-dictionary#auto-tracked-events) event, fired once per install |
| `com.google.android.gms:play-services-ads-identifier:18.0.1` | Google Advertising ID (`gaid`, `gaid_limit_ad_tracking`) |
**Using EDM4U (recommended)**
With the [External Dependency Manager for Unity](https://github.com/googlesamples/unity-jar-resolver) in your project, the Install Referrer dependency resolves automatically, since the SDK ships its own `ImmutableAudienceDependencies.xml`. Add only the GAID dependency to a `*Dependencies.xml` anywhere under an `Editor/` folder in your project:
```xml theme={null}
```
GAID is left to you (rather than declared by the SDK) so builds that don't collect it never request the `AD_ID` permission.
**Without EDM4U**
Enable **Custom Main Gradle Template** under **Player Settings → Publishing Settings**, then add both dependencies to `Assets/Plugins/Android/mainTemplate.gradle`. The `**DEPS**` token is a Unity placeholder expanded at build time, so do not remove it:
```gradle theme={null}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
**DEPS**
implementation 'com.android.installreferrer:installreferrer:2.2'
implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1'
}
```
Without the GAID dependency, a `ClassNotFoundException` is logged on each launch when `AUDIENCE_MOBILE_ATTRIBUTION` is set and `gaid` never ships. The SDK fetches GAID on a background thread (required by Google).
GAID is unavailable on first launch. The first [`game_launch`](/docs/products/audience/analytics/data-dictionary#auto-tracked-events) event will not include `gaid` or `gaid_limit_ad_tracking`. Both are available from the second launch onwards.
## 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`. |
| `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 `distribution_platform` property of the [`game_launch`](/docs/products/audience/analytics/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.
If you leave `DistributionPlatform` unset and the SDK detects Steam or Epic running at `Init` (see "Auto-collection on Steam and Epic" under `Identify` below), it fills this in for you. Setting `DistributionPlatform` explicitly always wins over auto-detection.
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/analytics/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. Prefer [`Track(IEvent)`](#trackievent) for predefined events instead of building the dictionary by hand. Property values can be any JSON-serializable primitive or string. Strings are truncated at 256 characters.
Throws `ArgumentException` if `eventName` is one of the reserved names (`purchase`, `progression`, `resource`, `achievement_unlocked`, `milestone_reached`) and a required field for that event is missing, even though this is the string overload rather than the typed one.
| 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` | 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 |
**Auto-collection on Steam and Epic**
When `DistributionPlatform` is `"steam"` or `"epic"` and consent is `Full` at `Init`, the SDK calls `Identify` for you. The platform session must already be active when `Init` runs:
* **Steam**: [Steamworks.NET](https://steamworks.github.io) or [Facepunch.Steamworks](https://wiki.facepunch.com/steamworks) present, with Steam running. If you use Facepunch, call `SteamClient.Init(appId)` before `ImmutableAudience.Init`.
* **Epic**: the [PlayEveryWare EOS Unity plugin](https://github.com/PlayEveryWare/eos_plugin_for_unity) installed, with EOS initialized and the player logged in.
Auto-collection runs once at `Init`. If your consent flow starts below `Full`, call `Identify` manually when the player grants full consent. If your game also has its own backend user ID, link it with `Alias` rather than calling `Identify` again.
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. Reset does not change the consent level, so studios using an explicit consent prompt should also call `SetConsent(ConsentLevel.None)` at logout if the next player should start unconsented.
To send queued events before they're discarded:
```csharp theme={null}
await ImmutableAudience.FlushAsync();
ImmutableAudience.Reset();
```
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. When calling `FlushAsync` in a logout flow, pass a `CancellationToken` with a deadline so the call does not hang if the network is unavailable at logout time, for example `new CancellationTokenSource(TimeSpan.FromSeconds(5)).Token`.
```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`. |
| `CurrentIdentityType` | `IdentityType?` | The provider from 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`. |
| `DeviceId` | `string?` | Persistent per-device ID that, unlike `AnonymousId`, survives `Reset`. Lets the backend resolve a returning player without a fresh `Identify` call. 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 server response). Event batches are retained for retry. |
| `ConsentPersistFailed` | Failed to persist the consent level to disk. The in-memory level still applies but reverts on next launch. |
When `Code` is `ValidationRejected`, `AudienceError.Rejections` tells you exactly which field failed and why, one entry per message in the rejected batch:
```csharp theme={null}
OnError = err =>
{
if (err.Rejections == null) return;
foreach (var rejection in err.Rejections)
foreach (var reason in rejection.Errors)
Debug.LogWarning($"{rejection.MessageId}: {reason.Field} - {reason.Message} ({reason.Code})");
};
```
## 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. Unity captures in-game actions, and the Web SDK covers web games, marketing sites, and single-page apps. All events flow to the same pipeline. See the [Data Dictionary](/docs/products/audience/analytics/data-dictionary) for the full per-event schema.
Erasure is server-side only: from your backend, call `DELETE /v1/audience/data` with the player's `userId` or `anonymousId`, authenticated with your secret API key. There is no in-game method for this, since the game client only ever holds a publishable key, which is public and can't prove ownership of the data being erased. See [Deleting User Data](/docs/products/audience/analytics/rest-api#deleting-user-data) for the request shape.
After your backend confirms the erasure request was accepted, call `Reset` on the client so subsequent activity isn't linked to the erased user:
```csharp theme={null}
ImmutableAudience.Reset();
```
Auto-collection fires at `Init` when consent is `Full` and the platform session is already active (Steam running, or EOS initialized and the player logged in). If consent is below `Full` at `Init`, or the platform session is not active yet, the SDK does not auto-identify. Call `Identify` yourself once the player grants full consent.
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 an email account, call `Alias` to tell the backend they are the same person. See [Identity Stitching](/docs/products/audience/analytics/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
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/analytics/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 Audience analytics instrumentation. It powers product analytics and [Attribution](/docs/products/attribution/overview): UTM parameters, click IDs, and referrer data are captured 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 [Unity SDK](/docs/products/audience/analytics/unity-sdk) and [REST API](/docs/products/audience/analytics/rest-api).
## What You Need
* An [Immutable Hub](https://hub.immutable.com) account with a project
* A publishable key from your project settings ([API keys guide](/docs/guides/advanced-setup/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](/docs/products/audience/analytics/web-sdk#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/analytics/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/analytics/data-dictionary#attribution-signals) parameters.
```typescript theme={null}
audience.page();
```
Log player actions with `track()`. The SDK ships with [predefined events](/docs/products/audience/analytics/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.Email, {
email: 'user@example.com',
});
```
```javascript theme={null}
audience.identify('player-123', ImmutableAudience.IdentityType.Email, {
email: 'user@example.com',
});
```
Flushes the queue and shuts down the SDK.
```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.Email, { email: 'user@example.com' });
// 6. Clean up on exit
audience.shutdown();
```
```html theme={null}
```
## Auto-Capture
The SDK captures form submissions, outbound link clicks, and scroll depth by default. Internal link clicks and button clicks are off by default, since they can be noisy on interactive pages. Turn them on where the extra signal is useful, such as measuring whether players open a `#trailer` modal or click a call-to-action.
Use the `autocapture` option to control what's collected:
```typescript theme={null}
const audience = Audience.init({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
consent: 'anonymous',
autocapture: {
internalClicks: true,
buttons: true,
forms: false,
},
});
```
Omit an option to keep its default. Set `forms`, `clicks`, and `scroll` to `false` to disable auto-capture entirely and rely only on your manual `page()` and `track()` calls.
See [`form_submitted`](/docs/products/audience/analytics/data-dictionary#auto-tracked-events) and [`scroll_depth`](/docs/products/audience/analytics/data-dictionary#auto-tracked-events) for exact property shapes. `link_clicked` shares its schema with the [predefined event](/docs/products/audience/analytics/data-dictionary#predefined-events) of the same name: a manual `track('link_clicked', ...)` call for a link auto-capture can't see looks identical on the wire.
## 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 `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'`). |
| `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 failures. `err.code` is one of `'FLUSH_FAILED'`, `'CONSENT_SYNC_FAILED'`, `'NETWORK_ERROR'`, `'VALIDATION_REJECTED'`, `'RATE_LIMITED'` |
| `autocapture.forms` | `boolean` | No | `true` | Auto-capture HTML form submissions |
| `autocapture.clicks` | `boolean` | No | `true` | Auto-capture clicks on links to external domains |
| `autocapture.internalClicks` | `boolean` | No | `false` | Auto-capture clicks on same-domain links, including `#hash` links |
| `autocapture.buttons` | `boolean` | No | `false` | Auto-capture button clicks. Form submit buttons are already captured by `form_submitted`, so they're excluded. |
| `autocapture.scroll` | `boolean` | No | `true` | Auto-capture scroll depth milestones |
```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/analytics/data-dictionary#consent-model) for consent levels and [downgrade behavior](/docs/products/audience/analytics/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/analytics/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/analytics/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', item_name: '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.Email, {
email: 'user@example.com',
});
```
**Identity types:**
| Value | Provider |
| ------------ | ---------------------- |
| `'passport'` | 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 an email 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.Email },
);
```
Returns the anonymous ID for the current browser session. Useful for debugging, passing the ID to a third-party tool, or carrying it across a browser redirect. See [Cross-Browser Tracking](#cross-browser-tracking).
```typescript theme={null}
const aid = audience.getAnonymousId();
```
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. Reset does not change the consent level, so studios using an explicit consent prompt should also call `setConsent('none')` at logout if the next player should start unconsented.
```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.
## Cross-Browser Tracking
When a player moves between domains or browser contexts, the SDK mints a fresh anonymous ID in the new context and their prior session history is disconnected.
To carry the anonymous ID across, append it to the URL:
```typescript theme={null}
const url = new URL(destination);
url.searchParams.set('imtbl_aid', audience.getAnonymousId());
window.open(url.toString(), '_blank');
// e.g. https://yoursite.com/?imtbl_aid=a1b2c3d4-...
```
The SDK reads `imtbl_aid` automatically on init, adopts it as the anonymous ID for that session, and removes the parameter from the URL. No extra code is needed on the landing page.
If the player logs in after the redirect, call `identify()` as normal. The anonymous session is linked to their account.
This works for any redirect where you control the destination URL. It does not apply when the OS or browser handles navigation directly (for example, a Share sheet or a bookmark), since there is no opportunity to append parameters before the URL is opened.
## 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
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 server-side only: call `DELETE /v1/audience/data` from your backend with the player's `userId` or `anonymousId`, authenticated with your secret API key. There is no client-side method for this, since a publishable key is public and can't prove ownership of the data being erased. See [Deleting User Data](/docs/products/audience/analytics/rest-api#deleting-user-data) in the REST API docs for the request shape.
After your backend confirms the erasure request was accepted, call `reset()` on the client so the next session isn't linked to the erased player:
```typescript theme={null}
audience.reset();
```
## Next Steps
How tracking data powers player attribution and Hub reports
Full reference of event schemas and consent levels
In-game tracking for Unity (desktop and mobile)
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
# Engage
Source: https://docs.immutable.com/docs/products/audience/engage
Message owned players so Steam wishlists and signups convert at launch
Engage sends lifecycle messages from [Audience CDP](/docs/products/audience/overview) profiles—email, Telegram, and related channels—so you are not dependent on a single Steam launch notification.
Configure campaigns in [Hub](https://hub.immutable.com). Profiles must already exist via [Ingestion](/docs/products/audience/ingestion) (Funnel Page, SDKs, or CSV).
## The Steam wishlist problem
Steam wishlists are the default pre-launch KPI for PC games. They are also a weak owned channel:
* You **cannot** email or message wishlisters yourself.
* Valve’s launch email competes with every other game launching that week.
* Months of silence between wishlist and ship cools intent.
* Paid UA that only optimizes for wishlists rarely proves it created *players*.
Engage does not replace Steam. It covers the gap Steam will not fill: an owned audience you can nurture, segment, and push at launch—ideally captured on a [Funnel Page](/docs/products/attribution/funnel-page) or your site while you still drive wishlists in parallel.
```
Steam-only: Ad → wishlist → long silence → Steam email → low convert
With Engage: Ad → Funnel Page + wishlist → nurture → launch push → install
```
## What Engage does
| Job | Detail |
| --------------- | ----------------------------------------------------------------------------------- |
| **Welcome** | Confirm signup, set expectations, drive one first action (quest, Discord, wishlist) |
| **Nurture** | Regular updates so the game stays familiar before launch |
| **Win-back** | Reach people who went quiet weeks or months ago |
| **Launch** | Countdown and launch-day pushes to the warmest segments |
| **Post-launch** | Bring wishlisters and signups who missed day one back into the game |
Messages use CDP traits and events—signup source, Funnel Page tier, last active date, quest completion—not a static CSV from six months ago.
## Typical setup for a Steam game
Send UA and social traffic to a [Funnel Page](/docs/products/attribution/funnel-page) (or Web SDK on your site) so players leave an email or linked account. Keep the Steam wishlist button as a parallel CTA.
Upload existing lists in Hub if you have them ([Ingestion](/docs/products/audience/ingestion)).
Enable welcome, nurture, win-back, and launch sequences for your game. Adjust segments when you care about high-intent vs cold traffic.
After launch, send session and progression events (Unity / REST) so win-back and post-launch campaigns use live behavior.
## Channels
| Channel | Use when |
| ------------ | ----------------------------------------------------------------------------- |
| **Email** | Primary owned channel for updates, countdowns, and launch |
| **Telegram** | Faster updates for players who opt in |
| **Push** | Time-sensitive prompts when you have a client or web surface that supports it |
Steam remains the storefront and wishlist surface. Engage is the channel you control.
## Targeting
Target on profile data from the CDP, for example:
* Signed up but never completed a quest
* Funnel Page tier or referral source
* Inactive for N days before launch
* Came from a paid campaign (via Attribution) but has not installed
Avoid blasting every address the same launch email. Cold and hot segments should not get the same cadence.
## Funnel Page triggers
When players live on a Funnel Page, common triggers include:
| Event | Example message |
| -------------- | ------------------------------------------------ |
| Signup | Welcome + next action (wishlist, quest, Discord) |
| Quest complete | Reward confirmation / next quest |
| Referral | Credit the referrer |
| Inactivity | Win-back with a concrete reason to return |
| Launch window | Countdown and “available on Steam” push |
## Next steps
Capture owned players next to the Steam CTA
Get signups and events into the CDP
See which segments are worth messaging
Tie spend to signups and installs—not only wishlists
# Ingestion
Source: https://docs.immutable.com/docs/products/audience/ingestion
Get player data into Audience CDP from SDKs, Funnel Page, and CSV upload
Ingestion is how player data enters Audience CDP. Most studios combine sources; the CDP merges them into one profile per player. The same events power [Analytics](/docs/products/audience/analytics/overview), [Engage](/docs/products/audience/engage), and [Attribution](/docs/products/attribution/overview).
## Sources at a glance
| Source | Best for | Start here |
| ------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **SDKs & REST API** | Live events from your site, game, or backend | [Web](/docs/products/audience/analytics/web-sdk) · [Unity](/docs/products/audience/analytics/unity-sdk) · [REST](/docs/products/audience/analytics/rest-api) |
| **Funnel Page** | Pre-launch signups and referrals on Immutable Play | [Funnel Page](/docs/products/attribution/funnel-page) |
| **CSV upload** | Existing waitlists and CRM exports | [Hub](https://hub.immutable.com) → your game → import |
Events from one source enrich profiles created by another. A CSV email that later signs in via the Web SDK becomes one player, not two.
## SDKs and REST API
Use first-party instrumentation when you control the client or server:
| Integration | Role |
| ------------- | ---------------------------------------------------------------------------------------------- |
| **Web SDK** | Landing pages, SPAs, web games—page views, custom events, UTM parameters, click IDs |
| **Unity SDK** | Sessions, progressions, purchases, and identity in native builds |
| **REST API** | Server-authoritative events (for example confirmed purchases) that must not rely on the client |
You need a Hub project and a [publishable key](/docs/guides/advanced-setup/api-keys). Schemas live in the [Data Dictionary](/docs/products/audience/analytics/data-dictionary).
Generate a publishable key for Sandbox, then Production when you ship. See [API Keys](/docs/guides/advanced-setup/api-keys).
Follow the Web or Unity quick start, or POST events to the REST API.
Call `identify` (or the REST equivalent) so anonymous activity joins the known profile.
Install and track on web
Instrument Unity games
Send events from your backend
Event and property schemas
## Funnel Page
A [Funnel Page](/docs/products/attribution/funnel-page) is a conversion-focused landing experience on Immutable Play. Signups, linked social accounts, referrals, and quest activity ingest into Audience CDP automatically—no extra site pixel required for that traffic.
Use it when you want Immutable to host pre-launch acquisition and still share profiles with your post-launch SDKs.
## CSV upload in Hub
Bring lists you already have (waitlists, CRM exports, creator audiences) into the CDP:
1. Open your game in [Hub](https://hub.immutable.com).
2. Open the audience import flow and choose **CSV upload**.
3. Map columns (email, external IDs, traits) to Audience fields.
4. Confirm. Matching identifiers merge into existing profiles; new identifiers create profiles.
Use stable IDs (email, Steam ID, your internal player ID). That is what lets later SDK events attach to the same person.
CSV is best for one-time or periodic imports. Pair it with SDKs or a Funnel Page so profiles keep updating after the upload.
## What to use when
| Situation | Recommended path |
| -------------------------------------- | -------------------------------------- |
| Pre-launch, minimal engineering | Funnel Page (+ CSV for existing lists) |
| Own marketing site | Web SDK |
| Unity game in development or live | Unity SDK |
| Authoritative purchase or login events | REST API |
| Migrating an old CRM | CSV first, then SDKs for new activity |
## Next steps
Message players from unified profiles
Explore quality, funnels, and segments
# Audience CDP
Source: https://docs.immutable.com/docs/products/audience/overview
Unify player data into profiles you can engage and analyze
Contact us for a free demo and onboarding
A **customer data platform (CDP)** collects player data from every touchpoint, resolves it into one profile per player, and makes that profile available to activate.
**Audience CDP** is collect → unify → activate. **[Attribution](/docs/products/attribution/overview)** measures which campaigns produced those players—on the same data.
## Built for Steam (and every other store)
Most PC games live or die on **Steam wishlists**. The store gives you a count, not a relationship:
| Steam wishlist alone | With Audience CDP |
| ------------------------------------ | -------------------------------------------------------------------------------------- |
| Anonymous intent | Known players you can message |
| No email or Telegram reach | Direct channels via [Engage](/docs/products/audience/engage) |
| One launch notification from Valve | Months of nurture before and after launch |
| No quality signal | See who is real vs low-intent traffic |
| Hard to tie ads to wishlist → player | Same profile for [Attribution](/docs/products/attribution/overview) and in-game events |
The usual pattern: run traffic to a [Funnel Page](/docs/products/attribution/funnel-page) (or your site with the Web SDK), keep wishlists as a parallel store goal, and use Engage so the people you *can* contact still convert when Steam’s email is not enough.
## Why it matters
Without a CDP, each channel keeps a partial view of the player:
| Source | Partial view |
| ----------------- | ------------------------------------------ |
| Steam wishlist | Intent, zero owned contact |
| Marketing site | Clicks and anonymous sessions |
| Discord / social | Community activity, weak game link |
| In-game telemetry | Behavior after install, weak campaign link |
| CRM spreadsheets | Stale lists with no live events |
Audience CDP merges those streams so Engage, Analytics, and webhooks share one definition of each player—and Attribution can credit the right source.
## How it works
Data enters from several sources, becomes one profile graph, then fans out to separate activation products—not a single blurry “activation” bucket.
```mermaid theme={null}
flowchart TB
subgraph ingest [Ingestion]
sdk[SDKs and REST API]
funnel[Funnel Page]
csv[CSV upload]
end
profiles[Unified player profiles]
sdk --> profiles
funnel --> profiles
csv --> profiles
profiles --> engage[Engage]
profiles --> analytics[Analytics]
profiles --> webhooks[Webhooks]
profiles --> attribution[Attribution]
```
1. **Ingest** — Live events from SDKs, Funnel Page signups, and CSV lists. See [Ingestion](/docs/products/audience/ingestion).
2. **Unify** — Anonymous sessions, logins, Steam and other linked accounts become one profile.
3. **Activate separately** — Message with [Engage](/docs/products/audience/engage), explore with [Analytics](/docs/products/audience/analytics/overview), sync out with [Webhooks](/docs/products/audience/webhooks), measure spend with Attribution.
## In this product
How data enters the CDP
Turn wishlists and signups into launch players
Quality, funnels, and segments
Push CDP events to your systems
Instrumentation reference (Web, Unity, REST, Data Dictionary) lives under **Attribution** in the sidebar—the same SDKs feed both products.
## Next steps
Wishlist-to-player messaging
Capture players Steam will not let you contact
# Webhooks
Source: https://docs.immutable.com/docs/products/audience/webhooks
Receive real-time Audience CDP events as signed HTTP callbacks
Audience webhooks push CDP events to your HTTPS endpoint as they happen—account links, and additional event types as the catalog grows. Delivery is a signed HTTP POST you configure once in Hub; new event types reuse the same envelope and verification flow.
## How delivery works
1. You register an endpoint in [Hub](https://hub.immutable.com) under **Settings** → **Webhooks**.
2. Immutable sends each event as a signed SNS notification to that URL.
3. Your server verifies the signature, acknowledges with `200`, and handles the payload asynchronously.
All Audience webhook types share the same outer envelope (`id`, `type`, `api_version`, `created_at`, `project_id`, `data`). When new types ship, you subscribe to them in Hub and branch on `type`—you do not need a new integration pattern.
## Available event types
The catalog starts with account-link lifecycle events. More Audience event types will be added over time; treat the table below as the current set, not the final set.
| Event | Fired when |
| --------------------------- | ----------------------------------------------------------------------- |
| `audience_joined` | A player newly enters your project's audience |
| `audience_account_linked` | A player links an external account while already in your audience |
| `audience_account_unlinked` | A player disconnects an external account while already in your audience |
Every event is scoped to whichever project a player's game currently maps to, not a project it used to belong to. If a game moves between projects, past events aren't regenerated for the new one.
Select the types you care about when you create or edit the webhook in Hub. Unsupported or future types simply do not appear until they are released.
## Configure in Hub
Go to [Hub](https://hub.immutable.com) → **Settings** → **Webhooks** → **Add Webhook**, enter your HTTPS URL, and choose which Audience event types to receive.
You can add up to 10 webhook endpoints per environment, so routing the same events to more than one system (for example, a production and a staging backend) doesn't require a second project.
## Payload shape
Every event uses this envelope. The `data` object is type-specific.
```json theme={null}
{
"id": "evt_11111111-1111-1111-1111-111111111111",
"type": "audience_joined",
"api_version": "2026-05-01",
"created_at": "2026-05-01T10:30:00.183947231Z",
"project_id": "22222222-2222-2222-2222-222222222222",
"data": {}
}
```
Bundles every account the player currently has linked into one event, rather than one event per provider.
| Field | Type | Description |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `player.id` | `string` | The player's identifier. |
| `linked_accounts` | `array` | Every provider account currently linked, each in the shape below. Empty if the player hasn't linked anything yet, a normal payload, not an error, use it as your general "player joined" signal. Never has two entries with the same `provider`. |
Shape of each entry in `linked_accounts`:
| Field | Type | Description |
| --------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `provider` | `string` | One of `steam`, `discord`, `epic_games`, `x`, `telegram`, `tiktok`, `twitch`. |
| `provider_account_id` | `string` | The account's ID on that provider. |
| `username` | `string` | Display name. Always `""` for `provider: "steam"`, its link flow doesn't return one. Other providers populate it. |
| `linked_at` | `string` (ISO 8601) | When that account was originally linked, not when `audience_joined` fired, which can be well before. Use the envelope's `created_at` for when the player joined. |
```json theme={null}
{
"id": "evt_11111111-1111-1111-1111-111111111111",
"type": "audience_joined",
"api_version": "2026-05-01",
"created_at": "2026-05-01T10:30:00.183947231Z",
"project_id": "22222222-2222-2222-2222-222222222222",
"data": {
"player": {
"id": "auth0|abc123"
},
"linked_accounts": [
{
"provider": "discord",
"provider_account_id": "111111111111111111",
"username": "player#1234",
"linked_at": "2026-03-15T08:12:00Z"
},
{
"provider": "steam",
"provider_account_id": "76561198000000000",
"username": "",
"linked_at": "2026-04-02T14:05:33Z"
}
]
}
}
```
**No guaranteed order against `audience_account_linked`.** If a player links an account and joins your audience around the same time, the two events can arrive in either order and may describe an account you've already seen from the other. Handle each independently.
Share the same shape.
| Field | Type | Description |
| ---------------- | -------- | ------------------------------------------------------------ |
| `player.id` | `string` | The player's identifier. |
| `linked_account` | `object` | The account that was linked or unlinked, in the shape below. |
Shape of `linked_account`:
| Field | Type | Description |
| --------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `provider` | `string` | One of `steam`, `discord`, `epic_games`, `x`, `telegram`, `tiktok`, `twitch`. |
| `provider_account_id` | `string` | The account's ID on that provider. |
| `username` | `string` | Display name. Always `""` for `provider: "steam"`, its link flow doesn't return one. Other providers populate it. |
| `linked_at` | `string` (ISO 8601) | When this link was made. A later relink on the same provider starts this over with its own new value, it doesn't stay pinned to the first link forever. |
```json theme={null}
{
"id": "evt_11111111-1111-1111-1111-111111111111",
"type": "audience_account_linked",
"api_version": "2026-05-01",
"created_at": "2026-05-01T10:30:00.183947231Z",
"project_id": "22222222-2222-2222-2222-222222222222",
"data": {
"player": {
"id": "auth0|abc123"
},
"linked_account": {
"provider": "steam",
"provider_account_id": "76561198000000000",
"username": "",
"linked_at": "2026-05-01T10:29:58Z"
}
}
}
```
For unlink events, `type` is `audience_account_unlinked`. All other fields are identical, including `linked_at`, it's still the time of the link being undone, not the unlink time. Use the envelope's `created_at` if you need the unlink time itself.
**A linked account belongs to at most one player at a time.** Linking a different account on the same provider is rejected until the current one is unlinked first.
## Verify the signature
Webhooks are signed by AWS SNS using RSA. Verify signatures using the [`sns-validator`](https://www.npmjs.com/package/sns-validator) npm package. There is no shared secret or custom header.
SNS delivers messages with `Content-Type: text/plain`. If you use Express, configure a raw text body parser for your webhook route:
```typescript theme={null}
app.use('/webhooks/immutable', express.text({ type: '*/*' }));
app.use('/webhooks/immutable', (req, res, next) => {
if (typeof req.body === 'string') req.body = JSON.parse(req.body);
next();
});
```
Then verify the signature and topic ARN:
```typescript theme={null}
import MessageValidator from 'sns-validator';
const validator = new MessageValidator();
const TOPIC_ARN = 'arn:aws:sns:us-east-2:362750628221:webhook-outbound-prod';
app.post('/webhooks/immutable', (req, res) => {
res.status(200).send('OK');
validator.validate(req.body, (err, message) => {
if (err) {
console.error('Invalid SNS signature', err);
return;
}
if (message.TopicArn !== TOPIC_ARN) {
console.error('Unexpected topic ARN', message.TopicArn);
return;
}
if (message.Type === 'SubscriptionConfirmation') {
fetch(message.SubscribeURL);
return;
}
const event = JSON.parse(message.Message);
handleEvent(event);
});
});
```
Checking `TopicArn` confirms the event originated from Immutable's infrastructure and not a third-party SNS topic.
Handle events by switching on `event.type` so new types can be added without changing verification.
## Retry policy
Failed deliveries retry automatically with exponential backoff: 15 attempts, starting at 5 seconds and increasing up to 260 seconds between tries. AWS caps total retry time at 60 minutes for any HTTPS endpoint, so retries stop within that window at the latest. After that, the event is held in a dead-letter queue for 14 days, then discarded.
There is no manual redelivery from Hub today. If your endpoint is down for longer than the retry window, treat those events as lost.
See the [SNS delivery retry policy](https://docs.aws.amazon.com/sns/latest/dg/sns-message-delivery-retries.html) for background on how AWS implements this.
## Best practices
* **Respond quickly.** Return `200` to SNS immediately and process the event asynchronously to avoid delivery timeouts.
* **Deduplicate.** Use the `id` field to detect duplicate deliveries. The same event may arrive more than once.
* **Order by `created_at`, not delivery order.** Events aren't guaranteed to arrive in the order they happened. Sort by the envelope's `created_at` if sequencing matters.
* **Scope by project.** The `project_id` field identifies which of your projects the player belongs to. A player can appear in multiple projects and trigger events for each.
* **Ignore unknown types safely.** Log and skip `type` values your code does not recognize so catalog growth does not break your endpoint.
# 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
1. **Select and connect** - The buyer chooses items in the Sale widget and connects their wallet.
2. **Quote** - The widget requests pricing from Immutable's API.
3. **Authorize and sign** - At checkout, Immutable authorizes the order and signs the purchase transaction.
4. **Pay** - The buyer pays. The signed transaction is submitted on-chain by their wallet for crypto, or by Transak for card payments.
5. **Mint and confirm** - The NFT is minted to the buyer's wallet as part of the on-chain transaction. Immutable then notifies your Confirmation webhook, if configured.
The widget handles wallet connection, insufficient-balance flows, and submitting the purchase transaction. Immutable's backend handles order validation, signing, and tracking confirmations. In Advanced mode, your backend supplies pricing and authorization through webhooks.
## Prerequisites
* [Hub project](/docs/guides/advanced-setup/api-keys) 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,
CommerceSuccessEventType,
CommerceFailureEventType,
} = 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, (payload) => {
if (payload.type === CommerceSuccessEventType.SALE_SUCCESS) {
// payload.data: { paymentMethod, tokenIds, transactions, transactionId }
console.log('Purchase complete:', payload.data);
widget.unmount();
}
});
widget.addListener(CommerceEventType.FAILURE, (payload) => {
if (payload.type === CommerceFailureEventType.SALE_FAILED) {
// payload.data: { reason, error, timestamp, paymentMethod, transactions, transactionId }
console.error('Purchase failed:', payload.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` | `CommerceFlowType.SALE` | Required. Selects the sale flow. |
| `items` | `SaleItem[]` | Items to purchase. Each item requires `productId`, `qty`, `name`, `image`, and `description`. |
| `environmentId` | `string` | Your Hub environment ID. Optional in the type, but required in practice—the widget warns and falls back to an empty value if it's missing. |
| `collectionName` | `string` | Collection name shown in the widget UI. Optional in the type, but expected in practice. |
| `excludePaymentTypes` | `SalePaymentTypes[]` | Payment methods to hide: `crypto`, `debit`, `credit`. |
| `preferredCurrency` | `string` | Overrides the backend's base settlement currency. |
| `excludeFiatCurrencies` | `string[]` | Fiat currencies to exclude from the on-ramp. |
| `customOrderData` | `Record` | Custom key-value pairs attached to the order. |
| `walletProviderName` | `WalletProviderName` | Default wallet provider if none is supplied. |
### Events
Each event payload has a `type` discriminator and a `data` object. Check `payload.type` before reading `payload.data`, since `SUCCESS` and `FAILURE` also fire for other steps in the flow (for example `SALE_TRANSACTION_SUCCESS`).
| Event | Description |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `SUCCESS` | A success step completed. For `CommerceSuccessEventType.SALE_SUCCESS`, `payload.data` is `{ paymentMethod, tokenIds, transactions: [{ method, hash }], transactionId }`. |
| `FAILURE` | A step failed. For `CommerceFailureEventType.SALE_FAILED`, `payload.data` is `{ reason, error, timestamp, paymentMethod, transactions, transactionId }`. |
| `CLOSE` | User closed the widget. Payload is `{}`. |
## 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/guides/advanced-setup/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/indexer/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
In Hub you configure each webhook as its own URL (with an optional API key). Immutable's backend calls them server-to-server. A complete order in Advanced mode flows through these stages:
1. **Quote** - The buyer selects items and the widget requests a quote. Immutable calls your Quote webhook for live pricing.
2. **Review** - The buyer reviews the order and chooses a payment currency.
3. **Authorize** - The buyer proceeds to pay. Immutable calls your Authorize webhook to reserve inventory and return token detail, then signs the purchase transaction.
4. **Payment executes** - The signed transaction is submitted on-chain and the NFT is minted to the buyer's wallet.
5. **Confirm** - Immutable calls your Confirmation webhook so you can fulfil or record the completed order.
6. **Expired** - If the authorized order isn't paid before it expires, Immutable calls your Expiration webhook so you can release the reserved inventory.
See [Webhooks](#webhooks) below for the full contract of each webhook.
## Webhooks
Primary Sales has two kinds of webhooks. You configure each one as an independent URL in Hub, each with its own optional API key, and Immutable's backend calls them server-to-server (sending the API key in the `Authorization` header). There is no shared base URL — the paths shown below are illustrative.
**Dynamic sale webhooks** — used only in **Advanced** (dynamic) sale mode:
* **Quote** — Immutable calls your backend for live pricing.
* **Authorize** — Immutable calls your backend to authorize and reserve the order.
In Simplified mode these are never called; pricing and authorization come from your Hub product catalog.
**Notification webhooks** — optional callbacks for order lifecycle events:
* **Confirmation** — sent in **both** Simplified and Advanced modes once the purchase transaction executes on-chain. Use it to fulfil, mint, or record the completed order.
* **Expiration** — sent when an authorized order expires without payment, in **Advanced mode only**. In Simplified mode, Immutable releases the reserved stock automatically and sends no callback.
Advanced mode only. Called when a user requests a price quote for items.
**Method:** `POST` to your configured **Quote URL**.
**Request:**
```json theme={null}
{
"recipient_address": "0x...",
"products": [
{ "product_id": "starter-pack", "quantity": 1 }
]
}
```
`recipient_address` may be `null` when the buyer's wallet is not yet known.
**Response:**
```json theme={null}
{
"products": [
{
"product_id": "starter-pack",
"quantity": 1,
"pricing": [
{
"currency": "USDC",
"currency_type": "crypto",
"amount": 9.99
},
{
"currency": "IMX",
"currency_type": "crypto",
"amount": 5.5
}
]
}
],
"totals": [
{
"currency": "USDC",
"currency_type": "crypto",
"amount": 9.99
},
{
"currency": "IMX",
"currency_type": "crypto",
"amount": 5.5
}
]
}
```
The buyer can pay in any settlement currency you've configured for the project in Hub, and the widget builds its currency options from that Hub configuration—**not** from this response. Your quote must therefore return both a per-product `pricing` row **and** a `totals` row for **every** configured currency. In the example above the project accepts both `USDC` and `IMX`, so each product is priced in both and `totals` carries a row for each.
Each product's `pricing` entries cover that single product (unit price times its `quantity`). Each `totals` entry is the sum across all products for that currency.
`amount` **must be a JSON number** (for example `9.99`), not a string. Returning `"9.99"` causes the quote to be rejected with a `502`. `totals` is an **array** of per-currency rows, not an object. `currency_type` is `crypto` or `fiat`.
Return a `pricing` and `totals` row for **every** currency configured in Hub. If a configured currency—especially the project's base currency—is missing from `totals`, the widget cannot price it and checkout fails when the buyer reaches the order review.
```typescript theme={null}
app.post('/quote', async (req, res) => {
const { recipient_address, products } = req.body;
// Validate products exist and are in stock
const validatedProducts = await validateProducts(products);
// Calculate pricing (can be dynamic based on user, time, etc.)
// amount must be a number; include a row for every currency
// configured in Hub, in both pricing and totals
const pricing = await calculatePricing(validatedProducts);
res.json({
products: pricing.products, // [{ product_id, quantity, pricing: [{ currency, currency_type, amount }] }]
totals: pricing.totals, // [{ currency, currency_type, amount }] — one per configured currency
});
});
```
Advanced mode only. Called when the user confirms the order and is ready to pay. Reserve inventory here.
**Method:** `POST` to your configured **Authorize URL**.
**Request:**
```json theme={null}
{
"spender_address": "0x...",
"recipient_address": "0x...",
"currency": "USDC",
"products": [
{ "product_id": "starter-pack", "quantity": 2 }
]
}
```
`spender_address` and `custom_data` (an arbitrary object) are optional. The request does **not** include a reference, total, or token IDs — your backend generates those and returns them in the response. The `currency` is the one the buyer selected, and your response **must** echo it back.
**Response:**
```json theme={null}
{
"reference": "order-123",
"currency": "USDC",
"products": [
{
"product_id": "starter-pack",
"collection_address": "0x...",
"contract_type": "ERC721",
"detail": [
{ "token_id": "12345", "amount": 9.99 },
{ "token_id": "12346", "amount": 9.99 }
]
}
]
}
```
The `detail` array is the heart of the response: **return one entry per token to be minted.** Immutable mints exactly one token for each `detail` entry, so the number of entries determines what the buyer receives. The example above authorizes `quantity: 2`, so it returns two `detail` entries.
Each `detail[].amount` is the **per-token** price in the order currency. Immutable charges the buyer the **sum of every `detail[].amount`** across all products (here, `9.99 + 9.99 = 19.98`), so do not put a line total in a single entry. Set `token_id` per token: for `ERC721`, a unique ID for each token; for `ERC1155`, the collection's configured token ID.
Your backend **generates and returns** the `reference` here (max **32 characters**) — a longer reference is rejected. Each `detail[].amount` must be a JSON number, not a string (a string body is rejected as malformed with a `502`). `contract_type` is `ERC721` or `ERC1155`. There is no `authorized` boolean — a `2xx` response with a valid body means authorized; return a `4xx` with `{ "code": "...", "message": "..." }` to reject.
```typescript theme={null}
app.post('/authorize', async (req, res) => {
const { spender_address, recipient_address, currency, products } = req.body;
try {
// Generate a reference (max 32 chars) and reserve inventory / token IDs.
// Return one detail entry per token to mint (detail.length === quantity);
// each detail.amount is the per-token price, summed into the order total.
const reference = createReference(); // e.g. "order-123"
const authorizedProducts = await reserveInventory(reference, products);
// Store the order for later confirmation
await createPendingOrder({
reference,
recipient_address,
spender_address,
currency,
products: authorizedProducts,
status: 'pending',
});
// Return the reserved token detail and pricing
res.json({
reference,
currency, // must match the requested currency
products: authorizedProducts, // [{ product_id, collection_address, contract_type, detail: [{ token_id, amount }] }]
});
} catch (error) {
// Reject with a 4xx and an error body
res.status(400).json({ code: 'INSUFFICIENT_STOCK', message: error.message });
}
});
```
Sent in both Simplified and Advanced modes after the purchase transaction executes on-chain. Mint or fulfil the order here.
**Method:** `POST` to your configured **Confirmation URL**.
**Request:**
```json theme={null}
{
"reference": "order-123",
"tx_hash": "0x...",
"recipient_address": "0x...",
"order": {
"contract_address": "0x...",
"total_amount": 9.99,
"deadline": 1700000000,
"created_at": 1700000000000,
"currency": "USDC",
"products": [
{
"product_id": "starter-pack",
"quantity": 1,
"collection_address": "0x...",
"collection_type": "ERC721",
"detail": [
{ "token_id": "12345", "amount": 9.99 }
]
}
]
}
}
```
Order fields are nested under `order`. `tx_hash`, `recipient_address`, and `custom_data` are sent when available. `total_amount` and each `detail[].amount` are numbers; `deadline` and `created_at` are Unix timestamps (seconds and milliseconds respectively).
**Response:**
The response **body is ignored** — only the HTTP status code matters. Return `2xx` (or `204`) to confirm the order; a `404` marks it unconfirmed, and `5xx` triggers a retry. A simple acknowledgement is fine:
```json theme={null}
{
"reference": "order-123",
"confirmed": true
}
```
```typescript theme={null}
app.post('/confirm', async (req, res) => {
const { reference, recipient_address, tx_hash, order } = 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 (token IDs come from order.products[].detail)
for (const product of order.products) {
for (const { token_id } of product.detail) {
await mintNFT({
recipient: recipient_address,
contractAddress: product.collection_address,
tokenId: token_id,
metadata: await getMetadata(product.product_id),
});
}
}
// Update order status
await updateOrderStatus(reference, 'completed');
// Status code is what matters — 2xx confirms the order
res.status(200).json({ reference, confirmed: true });
} catch (error) {
// Non-2xx (here 500) signals failure; 5xx is retried
res.status(500).json({ reference, confirmed: false, reason: error.message });
}
});
```
Advanced mode only. Sent when an authorized order expires without payment. Release reserved inventory. In Simplified mode, Immutable restocks automatically and this webhook is not called.
**Method:** `POST` to your configured **Expiration URL**.
**Request:**
```json theme={null}
{
"reference": "order-123"
}
```
The expiry request contains **only** the `reference`. Look up the order by reference to find the products and inventory to release.
**Response:**
The response **body is ignored** — only the HTTP status code matters. Return `2xx` (or `204`) to acknowledge; `5xx` triggers a retry. A simple acknowledgement is fine:
```json theme={null}
{
"reference": "order-123",
"expired": true
}
```
```typescript theme={null}
app.post('/expired', async (req, res) => {
const { reference } = req.body;
// Look up the order by reference, then release its reserved inventory
const order = await getOrderByReference(reference);
await releaseInventory(reference, order.products);
// Update order status
await updateOrderStatus(reference, 'expired');
// Status code is what matters — 2xx acknowledges the expiry
res.status(200).json({ reference, expired: true });
});
```
### Webhook security
Immutable authenticates every webhook call with the API key you configure for that webhook in Hub, sent in the `Authorization` header. Each webhook (quote, authorize, confirmation, expiration) has its own key. Reject any request whose `Authorization` header doesn't match:
```typescript theme={null}
// e.g. your quote webhook handler
app.post('/quote', (req, res) => {
const authHeader = req.headers['authorization'];
if (authHeader !== process.env.IMMUTABLE_QUOTE_API_KEY) {
return res.status(401).send('Unauthorized');
}
// ... handle the webhook
});
```
## Payment options
### Crypto
Users can pay with tokens on Immutable Chain. The accepted tokens can be configured through Hub. 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
Card payments are handled through [Transak](https://transak.com), Immutable's fiat payment partner. When a buyer selects **Debit Card** or **Credit Card** in the widget, the purchase is completed in an embedded Transak checkout, which accepts cards as well as Apple Pay and Google Pay where the buyer's device and region support them.
Funds settle to your configured payout wallet in the sale's settlement currency (commonly USDC, depending on your Hub configuration). KYC may be required for larger amounts.
Card payments currently process one NFT per order.
## Limits
To avoid issues with gas limits in a single transaction, it is recommended to split large orders 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
# 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/guides/advanced-setup/api-keys)
## 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
# Webhooks
Source: https://docs.immutable.com/docs/products/indexer/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
# 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 matched by metadata ID or field-level metadata criteria
# 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 must satisfy the bid’s matching spec — either its indexed **`metadata_id`** equals the bid’s `metadataId`, or its indexed metadata satisfies every filter in the bid’s `metadataCriteria`. 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 whose **indexed metadata** matches what you specify. A metadata bid can either target a group of tokens that share the **same metadata ID** (e.g. tokens minted from the same template), or it can match tokens by **field-level criteria** on the top-level metadata fields (`name`, `image`, `description`, `animation_url`, `external_url`, `youtube_url`) and individual attributes.
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** or **ERC-1155 collection** buy item.
* You attach **exactly one** matching spec:
* **`metadataId`** — a UUID that identifies a group of tokens sharing the same metadata definition in Immutable's [indexer](/docs/products/indexer/overview). The bid fills only on tokens whose indexed `metadata_id` equals this UUID.
* **`metadataCriteria`** — an array of field-level filters. Each filter has a **`fieldName`** (a top-level metadata field, or an attribute prefixed with `attribute:`) and a list of **allowed values**.
* **Matching semantics for `metadataCriteria`:** every filter you specify must be matched (filters AND). For a given filter, the token matches if its value is **one** of the allowed values (values OR). Matching is **case-insensitive**.
* **Matching semantics for `metadataId`:** the token's indexed `metadata_id` must equal the supplied UUID exactly.
* 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 purely by **individual trait attributes** (e.g. `Background` is `Blue`) without any top-level metadata fields, a **[trait bid](/docs/products/orderbook/trait-bids)** is a simpler choice. A metadata bid in `metadataCriteria` mode supports the same attribute filters plus top-level fields.
## 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 against your matching spec. 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.
## Choosing between `metadataId` and `metadataCriteria`
| Mode | Use it when |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `metadataId` | The marketplace already knows the metadata stack UUID, e.g. "all tokens minted from template `a1b2c3...`". One stable identifier, no field-level reasoning. |
| `metadataCriteria` | You want to bid on tokens whose metadata satisfies a logical AND of human-readable conditions, e.g. "name is `Cool Dragon` AND background is `Blue` or `Red`". |
Exactly one of the two must be set; supplying both, or neither, is rejected at the SDK and API levels.
## 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,
});
```
The `prepare`, approval, and signing steps are identical for both modes. The third step — calling `createMetadataBid` — is where you pick the matching spec.
```typescript theme={null}
// 3) Create a metadata bid that fills on any token with this metadata_id
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);
```
```typescript theme={null}
// 3) Create a metadata bid that fills on any token whose metadata satisfies
// all of the following:
// - name is "Cool Dragon"
// - background trait is one of "Blue" or "Red"
// - rarity trait is "Legendary"
const { result } = await orderbook.createMetadataBid({
orderComponents: prepared.orderComponents,
orderHash: prepared.orderHash,
orderSignature,
makerFees: [],
metadataCriteria: [
{ fieldName: 'name', values: ['Cool Dragon'] },
{ fieldName: 'attribute:Background', values: ['Blue', 'Red'] },
{ fieldName: 'attribute:Rarity', values: ['Legendary'] },
],
});
console.log('Metadata bid created:', result.id);
```
### Supported `fieldName` values
In `metadataCriteria` mode, `fieldName` must be one of:
| Pattern | Meaning |
| ------------------------ | --------------------------------------------------------------------------------------------------------- |
| `name` | Matches the NFT’s top-level `name` field |
| `image` | Matches the NFT’s top-level `image` field |
| `description` | Matches the NFT’s top-level `description` field |
| `animation_url` | Matches the NFT’s top-level `animation_url` field |
| `external_url` | Matches the NFT’s top-level `external_url` field |
| `youtube_url` | Matches the NFT’s top-level `youtube_url` field |
| `attribute:` | Matches the value of the attribute whose `trait_type` equals `` (e.g. `attribute:Background`) |
Each `fieldName` may appear **at most once** per bid. The SDK rejects duplicates client-side — combine multiple acceptable values into a single filter's `values` array (OR) instead.
## 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 indexed metadata does not match the bid's matching spec — `metadata_id` mismatch in `metadataId` mode, or any filter unsatisfied in `metadataCriteria` mode — 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) {
if (bid.metadataId) {
console.log(bid.id, 'by id:', bid.metadataId);
} else {
console.log(bid.id, 'by criteria:', bid.metadataCriteria);
}
}
```
### Get one metadata bid
```typescript theme={null}
const { result: bid } = await orderbook.getMetadataBid(metadataBidId);
console.log({
id: bid.id,
status: bid.status,
// Exactly one of these is populated for any given bid:
metadataId: bid.metadataId,
metadataCriteria: bid.metadataCriteria,
sell: bid.sell,
buy: bid.buy,
});
```
The response shape is the same for both modes — `metadataId` is `undefined` for criteria-mode bids, and `metadataCriteria` is an empty array for id-mode bids.
## 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 attributes satisfy `traitCriteria` | Any token matching `metadataId` **or** `metadataCriteria` |
| **Filter mechanism** | None | Array of attribute filters | UUID **or** field-level filters spanning top-level metadata fields and attributes |
| **Fulfillment validation** | Token exists in collection | Token attributes satisfy all trait filters | Token `metadata_id` matches (id mode) **or** token metadata satisfies all field filters (criteria mode) |
| **Typical use** | Floor sweep / any item | Offers on attribute-filtered sets (e.g. legendary + blue) | Offers on tokens from a specific template (id mode) or on human-readable metadata conditions (criteria mode) |
## 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/indexer/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 **`metadata_id`** / **`metadataId`** (for id-mode bids) or **`metadata_criteria`** / **`metadataCriteria`** (for criteria-mode bids). Exactly one of those two fields is populated per metadata bid.
### 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 **exactly one** of **`metadata_id`** or **`metadata_criteria`**, depending on how the bid was created.
```json theme={null}
// metadata_id mode
{
"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" }]
}
}
// metadata_criteria mode
{
"event_name": "imtbl_order_updated",
"data": {
"id": "metadata-bid-790",
"status": "ACTIVE",
"type": "METADATA_BID",
"metadata_criteria": [
{ "field_name": "name", "values": ["Cool Dragon"] },
{ "field_name": "attribute:Background", "values": ["Blue", "Red"] }
],
"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 or field-criteria metadata 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**, or whose metadata matches **field-level criteria** | Buyer | "Offering 0.4 IMX for any Sword with metadata-id 01234-abcde..." or "any Sword named Cool Dragon with Background Blue" |
### 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 by metadata ID or by field-level metadata criteria
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 attributes match `traitCriteria` | Any token matching `metadataId` **or** `metadataCriteria` (top-level fields and/or attributes) |
| **Fulfillment** | Seller sells that token | Seller passes **`tokenId`** | Seller passes **`tokenId`** + attributes must match | Seller passes **`tokenId`** + token must match the bid's `metadataId` or `metadataCriteria` |
| **Typical use** | Target a rare ID | Floor sweep / any item | Offers on **filtered** attribute sets (e.g. legendary + blue background) | Offers on tokens from a specific template (id mode), or human-readable metadata conditions spanning top-level fields and attributes (criteria mode) |
## 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 matched by metadata ID or field-level metadata criteria
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 [Player Support](https://play.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/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 } from '@imtbl/auth';
export const auth = new Auth({
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 (
loginWithEmbedded(loginConfig)} disabled={isLoggingIn}>
{isLoggingIn ? 'Signing in...' : 'Login with Passport'}
);
}
```
### 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 (
);
}
```
```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 Claim ;
}
```
### 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 Get ID Token ;
}
```
```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 (
<>
logout()} disabled={isLoggingOut}>
{isLoggingOut ? 'Signing out...' : 'Sign Out'}
{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.
signOut()}>Sign Out
);
}
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 Fetch Data ;
}
```
| Session Error | Description | Action |
| --------------------- | --------------------- | ------------------------------------------- |
| `"TokenExpired"` | Access token expired | Handled automatically by `getAccessToken()` |
| `"RefreshTokenError"` | Refresh token invalid | Prompt user to sign in again |
```typescript theme={null}
import { PassportError, PassportErrorType } from '@imtbl/auth';
async function handleLogin() {
try {
await auth.login();
console.log('Login successful');
} catch (error) {
if (error instanceof PassportError) {
switch (error.type) {
case PassportErrorType.AUTHENTICATION_ERROR:
console.error('Login failed:', error.message);
break;
case PassportErrorType.USER_REGISTRATION_ERROR:
console.error('User registration failed:', error.message);
break;
case PassportErrorType.WALLET_CONNECTION_ERROR:
console.error('Wallet connection failed:', error.message);
break;
default:
console.error('Unknown error:', error);
}
}
throw error;
}
}
```
### Common Error Types
| Error Type | Description | Recommended Action |
| ------------------------- | ------------------------------ | ----------------------------------------------- |
| `AUTHENTICATION_ERROR` | Login or authentication failed | Ask user to try again or use different provider |
| `USER_REGISTRATION_ERROR` | User registration failed | Check if user already exists or retry |
| `WALLET_CONNECTION_ERROR` | Failed to connect wallet | Retry connection or check network |
| `INVALID_CONFIGURATION` | Invalid Passport configuration | Verify `clientId` and `redirectUri` |
## Next Steps
Integrate with Next.js, Vite, and other frameworks
Connect wallet, send transactions, and sign messages
Understand the security model
# Passport Clients
Source: https://docs.immutable.com/docs/products/passport/clients
Configure Passport authentication clients for your game or application
## Create a Passport Client
Create client in [Hub](https://hub.immutable.com): **Tools** → **Passport** → **Add Client**
**Application Type:** Native (Unity/Unreal) or Web (browser)
**Redirect URLs:** Match `redirectUri` in code
**Logout URLs:** Match `logoutRedirectUri` in code
## Redirect URI Configuration
### Web Applications
| Environment | Example |
| ----------- | -------------------------------- |
| Development | `http://localhost:3000/callback` |
| Production | `https://yourgame.com/callback` |
### Native Applications (Unity/Unreal)
| Platform | Example |
| -------------------- | ------------------ |
| Mobile (iOS/Android) | `myapp://callback` |
| Desktop | `myapp://callback` |
## Multiple Environments
```
http://localhost:3000/callback # Local development
https://staging.yourgame.com/callback # Staging
https://yourgame.com/callback # Production
```
URIs must be exact matches (no wildcards).
## Client Types
### Native Clients
For Unity, Unreal, mobile apps:
* Uses PKCE for security
* Custom URL schemes for redirects
* No client secret required
### Web Clients
For browser applications:
* Standard OAuth 2.0 flow
* HTTPS redirect URIs
* CORS origins configuration
## Next Steps
Login flows
How Passport works
# Gas Sponsorship
Source: https://docs.immutable.com/docs/products/passport/gas-sponsorship
Understanding gas sponsorship on Immutable zkEVM
## Overview
Immutable sponsors gas costs for all Passport users, enabling "Gas Free for Gamers"—seamless blockchain experiences where players never encounter transaction fees.
## What is Gas Sponsorship?
Exposing gas costs to end users creates significant user experience friction in blockchain games. Players must acquire cryptocurrency, understand transaction fees, and manage wallet balances before they can play.
Gas sponsorship eliminates this friction entirely. Immutable sponsors transaction costs so players can trade, mint, transfer, and play without ever seeing blockchain fees. Studios treat gas sponsorship as an operational expense—similar to server infrastructure costs.
## Why "Gas Free for Gamers"?
Gas sponsorship delivers measurable business benefits:
* **Higher retention rates** - Players don't abandon due to transaction fee friction
* **Better conversion** - No cryptocurrency barrier to entry
* **Seamless onboarding** - Web2-like experience for Web3 games
## Eligibility
Gas sponsorship is available exclusively for **Immutable Passport users**.
## Getting Started
Set up Passport to enable gas sponsorship
Mint assets with automatic gas sponsorship
Monitor gas costs in [Immutable Hub](/docs/guides/advanced-setup/api-keys)
Build and send sponsored transactions
## Next Steps
Build wallet features
Add funding options for players
Mint NFTs with sponsored gas
Learn about Immutable Chain gas
# Passport
Source: https://docs.immutable.com/docs/products/passport/overview
Seamless authentication and embedded wallets for games
Passport is Immutable's identity and wallet solution. Players sign in with familiar accounts (Google, Apple, email) and get a blockchain wallet that works across all Immutable games—no extensions, no seed phrases, no friction.
Login flows, sessions, and identity management
Transactions, signing, and blockchain interactions
Seamless in-game transactions for native clients
Security model and key management
## Why Passport?
Players sign in with Google, Apple, or email—accounts they already have. No wallet downloads, no browser extensions, no seed phrases. The wallet is created automatically.
Unlike other embedded wallets that create a new address per app, Passport gives users one wallet across all Immutable games. Assets, identity, and reputation stay unified.
Gain instant access to millions of players who already have a Passport account.
Users log in on any device to access their wallet. No seed phrase backup or manual imports—just authenticate with the same social account.
Pre-approved transactions let whitelisted game actions execute instantly without popups. Critical for real-time gameplay.
Users own their keys. Immutable cannot move funds without user consent—we're a co-signer for security policies, not a custodian.
See detailed comparison with MetaMask, Privy, and other wallet solutions →
## Quick Start
Install the SDK and connect a user's wallet in one step:
```bash theme={null}
npm install @imtbl/wallet
```
```typescript theme={null}
import { connectWallet } from '@imtbl/wallet';
// Connect wallet (prompts login if needed)
const provider = await connectWallet();
// Get the user's wallet address
const accounts = await provider.request({ method: 'eth_requestAccounts' });
console.log('Wallet:', accounts[0]);
```
That's it. The `connectWallet` function handles authentication automatically—users see Passport's login screen if they're not already signed in.
Some features—such as customizing the login experience, using Passport inside a game, or accessing user identity—require setting up an Auth client. See the [Authentication](/docs/products/passport/authentication) guide for details.
For Unity and Unreal integration, see the [Authentication](/docs/products/passport/authentication) guide.
## Chain Support
| Chain | Network ID | Status |
| ----------------- | ---------- | ------------------------------- |
| Immutable Mainnet | 13371 | Full Support |
| Immutable Testnet | 13473 | Full Support |
| Ethereum Mainnet | 1 | Limited support, ejections only |
| Other EVM Chains | Various | Support coming in future |
Do not send funds to your Passport address on unsupported chains.
If you have sent funds on Ethereum mainnet, you can use the wallet functionality on [Immutable Play](https://play.immutable.com) to eject those funds to Immutable Chain.
If you have sent funds to your Passport address on any other chain, your funds will be stuck indefinitely. Please raise a ticket with [Player Support](https://play.immutable.com), but this will not be able to be resolved until Passport supports all EVM chains.
## Account Management
### Deleting your Passport account
**Important**: Once you delete your Passport account, the associated wallet and all assets within it will become **irrecoverable**. Be sure to transfer all assets you wish to keep before initiating the account deletion process.
Note that this deletion process may take up to 30 days to complete. Please refer to our [privacy policy](https://www.immutable.com/privacy-policy) for further information.
#### Before deleting: Transfer your assets
To transfer your assets from Passport to another wallet:
Visit a marketplace, such as [TokenTrove](https://tokentrove.com)
Specify the destination wallet address where you'd like to transfer your assets
Complete the transfer process for all assets you want to keep
Verify that all assets have been successfully transferred from your Passport wallet
#### How to delete your account
Visit [Immutable Play](https://play.immutable.com)
Click the Help widget in the bottom-left corner
Start a chat with the AI agent and request Passport account deletion
Complete the guided steps provided by the support system
## Next Steps
Implement login, logout, and session management
Send transactions and sign messages
# Pre-Approved Transactions
Source: https://docs.immutable.com/docs/products/passport/pre-approved-transactions
Enable seamless in-game transactions without confirmation popups for immersive gameplay
Pre-approved transactions allow players to execute in-game blockchain transactions—such as crafting, trading, and transferring assets—without disruptive confirmation popups. This feature keeps players immersed in gameplay while maintaining the security benefits of blockchain ownership.
Pre-approved transactions are only available in **native clients** (Unity and Unreal games on mobile and desktop). Web transactions currently always require explicit confirmation popups.
## Prerequisites
Before using pre-approved transactions, ensure:
1. **User Authentication**: User must be [authenticated](/docs/products/passport/authentication) with Passport
2. **Wallet Initialization**: Call `ConnectEvm()` to initialize the zkEVM wallet provider
3. **Native Client**: Using Unity or Unreal SDK (not TypeScript/web)
4. **Contracts Linked**: Your contracts must be linked in Immutable Hub
## How It Works
Pre-approved transactions eliminate confirmation popups for in-game blockchain actions:
1. **One-time consent** - Users approve your game once for seamless transactions
2. **Automatic execution** - All future transactions to linked contracts execute instantly
3. **Gas-free** - Immutable sponsors transaction fees automatically
This keeps players immersed in gameplay while maintaining blockchain security.
## Supported Transactions
All transactions to **linked contracts** (ERC-20, ERC-721, ERC-1155) are automatically pre-approved:
* Transfers, mints, burns
* Custom contract interactions (crafting, trading, etc.)
**Not supported:** Native IMX transfers require user confirmation.
## Enabling Pre-Approved Transactions
### Step 1: Link Your Contracts in Hub
Your contracts must be linked in Immutable Hub to enable pre-approved transactions.
Complete guide to linking contracts, verification process, and API details
Linked contracts are automatically eligible for pre-approved transactions and gas sponsorship.
### Step 2: Create a Native Passport Client
Pre-approved transactions require a **Native** (not Web) Passport client.
Step-by-step guide to creating a Passport client in Hub
Choose "Native" application type when creating your client. Pre-approved transactions will be available for all native clients in your organization.
### Step 3: Implementation
Pre-approval happens automatically when you use standard Passport transaction methods with linked contracts. See [Unity SDK](/docs/sdks/unity/overview) or [Unreal SDK](/docs/sdks/unreal/overview) for implementation details.
## Next Steps
Link and verify your contracts in Immutable Hub
Get started with Unity SDK integration
Get started with Unreal SDK integration
# Wallet
Source: https://docs.immutable.com/docs/products/passport/wallet
Every Passport user has an embedded wallet on Immutable Chain. This guide covers wallet operations for developers integrating Passport into games and applications.
**For end users**: Players can manage their wallet, view balances, and browse transaction history at [Immutable Play](https://play.immutable.com). Direct your users there for wallet management features.
## Overview
This page covers essential wallet operations including retrieving wallet addresses, checking native IMX and ERC-20 token balances, sending transactions (transfers, contract interactions, NFTs), signing messages (ERC-191 personal sign and EIP-712 typed data), and error handling patterns.
## Prerequisites
User must be authenticated with Passport before using wallet operations
**Web vs Native**: Some features like [pre-approved transactions](/docs/products/passport/pre-approved-transactions) require Unity/Unreal native clients and cannot be used in web browsers.
## Wallet Connect
Connect the EVM provider and request account access. Required for all wallet operations including [Checkout](/docs/products/checkout/overview) wallet funding and [Orderbook](/docs/products/orderbook/overview) trading operations.
Pass `getUser` from `useImmutableSession` to `connectWallet` so the wallet uses your server-managed session instead of its own OAuth flow.
```tsx theme={null}
'use client';
import { useImmutableSession } from '@imtbl/auth-next-client';
import { connectWallet, type ZkEvmProvider } from '@imtbl/wallet';
import { useState } from 'react';
function WalletConnect() {
const { isAuthenticated, getUser } = useImmutableSession();
const [address, setAddress] = useState(null);
async function handleConnect() {
const provider = await connectWallet({
getUser: isAuthenticated ? getUser : undefined,
clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
}) as ZkEvmProvider;
const accounts = await provider.request({ method: 'eth_requestAccounts' });
setAddress(accounts[0]);
}
return (
{address ? (
Wallet connected: {address}
) : (
Connect Wallet
)}
);
}
```
When `getUser` is provided, the wallet skips its own OAuth flow and relies on the server-managed session. This avoids duplicate login prompts and keeps token refresh in one place. Pass `undefined` when the user is not authenticated to allow the wallet to use its own login flow.
```typescript theme={null}
// Assuming Passport is initialized and user is logged in
// 1. Connect EVM provider
const provider = passportInstance.connectEvm();
// 2. Request accounts (connect wallet)
const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];
console.log('Wallet connected:', address);
```
The `connectEvm()` method returns an [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) compatible provider that can be used with libraries like [ethers.js](https://docs.ethers.org/).
```csharp theme={null}
// Assuming Passport is initialized and user is logged in
// 1. Connect EVM provider
await passport.ConnectEvm();
// 2. Request accounts (connect wallet)
var accounts = await passport.ZkEvmRequestAccounts();
string address = accounts[0];
Debug.Log($"Wallet connected: {address}");
```
Call `ConnectEvm()` once after login, then use `ZkEvmRequestAccounts()` to get the wallet address. This initializes the zkEVM provider for all subsequent wallet operations.
```cpp theme={null}
// Add to your subsystem:
public:
void ConnectWallet()
{
auto Passport = GetPassport();
if (!Passport)
{
return;
}
// 1. Connect EVM provider
Passport->ConnectEvm(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnWalletEvmConnected));
}
private:
void OnWalletEvmConnected(FImmutablePassportResult Result)
{
if (!Result.Success)
{
UE_LOG(LogTemp, Error, TEXT("ConnectEvm Failed: %s"), *Result.Error);
return;
}
auto Passport = GetPassport();
if (!Passport)
{
return;
}
// 2. Request accounts (connect wallet)
Passport->ZkEvmRequestAccounts(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnWalletAccountsReceived));
}
void OnWalletAccountsReceived(FImmutablePassportResult Result)
{
if (!Result.Success)
{
UE_LOG(LogTemp, Error, TEXT("Request Accounts Failed: %s"), *Result.Error);
return;
}
const auto& RequestAccountsData = FImmutablePassportZkEvmRequestAccountsData::FromJsonObject(Result.Response.JsonObject);
if (!RequestAccountsData.IsSet())
{
UE_LOG(LogTemp, Error, TEXT("Failed to parse accounts data"));
return;
}
const auto& Accounts = RequestAccountsData->accounts;
for (int Index = 0; Index < Accounts.Num(); Index++)
{
UE_LOG(LogTemp, Log, TEXT("Account[%d]: %s"), Index, *Accounts[Index]);
}
}
void OnLoginComplete(FImmutablePassportResult Result)
{
if (!Result.Success) return;
UE_LOG(LogTemp, Log, TEXT("Login Successful"));
ConnectWallet();
}
```
Follow the sequence: ConnectEvm → ZkEvmRequestAccounts. Use `FImmutablePassportZkEvmRequestAccountsData::FromJsonObject` to parse the response.
**Important**: Before connecting the wallet, ensure you have [initialized Passport and logged in the user](/docs/products/passport/authentication). Then complete these steps:
1. Connect EVM provider (`connectEvm()` / `ConnectEvm()`)
2. Request accounts (`eth_requestAccounts` / `ZkEvmRequestAccounts()`)
**Next.js:** Once you have the provider from `connectWallet({ getUser })` (shown in the Next.js tab above), all wallet operations below work the same way as the TypeScript examples -- the provider is the same [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) compatible `ZkEvmProvider`. Use the TypeScript tab code with your Next.js provider.
## Configuration
The `connectWallet` function accepts configuration options to customize the wallet provider.
### Custom Client ID
```typescript theme={null}
const provider = await connectWallet({
clientId: 'your-client-id',
});
```
### Chain Selection
```typescript theme={null}
import {
connectWallet,
IMMUTABLE_ZKEVM_MAINNET_CHAIN,
IMMUTABLE_ZKEVM_TESTNET_CHAIN,
} from '@imtbl/wallet';
const provider = await connectWallet({
chains: [IMMUTABLE_ZKEVM_MAINNET_CHAIN],
});
```
### Popup Overlay Options
```typescript theme={null}
const provider = await connectWallet({
popupOverlayOptions: {
disableGenericPopupOverlay: true,
disableBlockedPopupOverlay: true,
},
});
```
### All Options
| Option | Type | Default | Description |
| --------------------- | --------------------- | -------------------- | --------------------------------------------- |
| `clientId` | `string` | Auto-detected | Immutable client ID |
| `chains` | `ChainConfig[]` | `[testnet, mainnet]` | Chain configurations |
| `initialChainId` | `number` | First chain | Initial chain to connect to |
| `popupOverlayOptions` | `PopupOverlayOptions` | - | Options for login popup overlays |
| `announceProvider` | `boolean` | `true` | Whether to announce via EIP-6963 |
| `feeTokenSymbol` | `string` | `'IMX'` | Preferred token symbol for relayer fees |
| `getUser` | `GetUserFunction` | Internal | Custom function for external auth integration |
### Using @imtbl/auth (Direct)
For applications that manage authentication outside of Next.js, wrap the `Auth` class in a `getUser` function:
```typescript theme={null}
import { connectWallet } from '@imtbl/wallet';
import { Auth } from '@imtbl/auth';
const auth = new Auth({
clientId: 'your-client-id',
redirectUri: 'https://your-app.com/callback',
});
const getUser = async (forceRefresh?: boolean) => {
if (forceRefresh) {
return auth.forceUserRefresh();
}
return auth.getUser();
};
const provider = await connectWallet({ getUser });
```
## Chain Configuration
### Chain Constants
```typescript theme={null}
import {
IMMUTABLE_ZKEVM_MAINNET_CHAIN_ID, // 13371
IMMUTABLE_ZKEVM_TESTNET_CHAIN_ID, // 13473
} from '@imtbl/wallet';
```
### Presets
```typescript theme={null}
import {
IMMUTABLE_ZKEVM_MAINNET_CHAIN,
IMMUTABLE_ZKEVM_TESTNET_CHAIN,
DEFAULT_CHAINS,
} from '@imtbl/wallet';
// Testnet only
const provider = await connectWallet({
chains: [IMMUTABLE_ZKEVM_TESTNET_CHAIN],
});
// Mainnet only
const provider = await connectWallet({
chains: [IMMUTABLE_ZKEVM_MAINNET_CHAIN],
});
// Both (default)
const provider = await connectWallet({
chains: DEFAULT_CHAINS,
});
```
Spread-friendly presets are also available:
```typescript theme={null}
import {
IMMUTABLE_ZKEVM_MAINNET,
IMMUTABLE_ZKEVM_TESTNET,
IMMUTABLE_ZKEVM_MULTICHAIN,
} from '@imtbl/wallet';
const provider = await connectWallet({
...IMMUTABLE_ZKEVM_MAINNET,
});
```
### Custom Chain Configuration
```typescript theme={null}
import type { ChainConfig } from '@imtbl/wallet';
const customChain: ChainConfig = {
chainId: 13473,
name: 'Immutable zkEVM Testnet',
rpcUrl: 'https://rpc.testnet.immutable.com',
relayerUrl: 'https://api.sandbox.immutable.com/relayer-mr',
apiUrl: 'https://api.sandbox.immutable.com',
passportDomain: 'https://passport.sandbox.immutable.com',
};
```
## Get Wallet Address
```typescript theme={null}
// First, initialize the zkEVM provider
const provider = await connectWallet({ auth });
// Then get the wallet address
const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];
console.log('Wallet address:', address);
```
```csharp theme={null}
// First, initialize the zkEVM provider (call once after login)
await passport.ConnectEvm();
// Then get the wallet address
var accounts = await passport.ZkEvmRequestAccounts();
string address = accounts[0];
Debug.Log($"Wallet address: {address}");
```
```cpp theme={null}
// Add to your subsystem:
public:
void GetWalletAddress()
{
auto Passport = GetPassport();
if (!Passport)
{
return;
}
// First, initialize the zkEVM provider (call once after login)
Passport->ConnectEvm(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnEvmConnected));
}
private:
void OnEvmConnected(FImmutablePassportResult Result)
{
if (!Result.Success)
{
UE_LOG(LogTemp, Error, TEXT("ConnectEvm Failed: %s"), *Result.Error);
return;
}
auto Passport = GetPassport();
if (!Passport)
{
return;
}
// Then get the wallet address
Passport->ZkEvmRequestAccounts(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnAccountsReceived));
}
void OnAccountsReceived(FImmutablePassportResult Result)
{
if (!Result.Success)
{
UE_LOG(LogTemp, Error, TEXT("Request Accounts Failed: %s"), *Result.Error);
return;
}
const auto& RequestAccountsData = FImmutablePassportZkEvmRequestAccountsData::FromJsonObject(Result.Response.JsonObject);
if (!RequestAccountsData.IsSet())
{
UE_LOG(LogTemp, Error, TEXT("Failed to parse accounts data"));
return;
}
if (RequestAccountsData->accounts.Num() > 0)
{
FString Address = RequestAccountsData->accounts[0];
UE_LOG(LogTemp, Log, TEXT("Wallet Address: %s"), *Address);
}
}
void OnWalletAccountsReceived(FImmutablePassportResult Result)
{
if (!Result.Success) return;
UE_LOG(LogTemp, Log, TEXT("Wallet Connected"));
GetWalletAddress();
}
```
## Linked Addresses
Users can link external wallets (like MetaMask, WalletConnect, etc.) to their Passport account, allowing them to use the same identity across multiple wallets.
```typescript theme={null}
import { getLinkedAddresses } from '@imtbl/wallet';
import { ImmutableConfiguration } from '@imtbl/config';
import { Environment } from '@imtbl/config';
// Initialize API client
const apiClient = new ImmutableConfiguration({
environment: Environment.SANDBOX,
}).getMultiRollupApiClients();
// Get all wallets linked to the current Passport account
const linkedAddresses = await getLinkedAddresses(auth, apiClient);
console.log('Linked addresses:', linkedAddresses);
// Returns: ['0x123...', '0x456...']
```
```csharp theme={null}
List addresses = await Passport.Instance.GetLinkedAddresses();
if (addresses.Count > 0)
{
Debug.Log("Linked addresses:");
foreach (string address in addresses)
{
Debug.Log($" {address}");
}
}
else
{
Debug.Log("No linked addresses");
}
```
```cpp theme={null}
// Add to your subsystem:
public:
void GetLinkedAddresses()
{
auto Passport = GetPassport();
if (!Passport)
{
return;
}
Passport->GetLinkedAddresses(UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnLinkedAddressesReceived));
}
private:
void OnLinkedAddressesReceived(FImmutablePassportResult Result)
{
if (!Result.Success)
{
UE_LOG(LogTemp, Error, TEXT("Get Linked Addresses Failed: %s"), *Result.Error);
return;
}
TArray LinkedAddresses = UImmutablePassport::GetResponseResultAsStringArray(Result.Response);
if (LinkedAddresses.Num() > 0)
{
UE_LOG(LogTemp, Log, TEXT("Found %d Linked Address(es)"), LinkedAddresses.Num());
for (int Index = 0; Index < LinkedAddresses.Num(); Index++)
{
UE_LOG(LogTemp, Log, TEXT(" Linked[%d]: %s"), Index, *LinkedAddresses[Index]);
}
}
else
{
UE_LOG(LogTemp, Log, TEXT("No Linked Addresses Found"));
}
}
void OnWalletAccountsReceived(FImmutablePassportResult Result)
{
if (!Result.Success) return;
UE_LOG(LogTemp, Log, TEXT("Wallet Connected"));
GetLinkedAddresses();
}
```
**Important**: `ConnectEvm()` must be called once after login to initialize the zkEVM provider before calling `ZkEvmRequestAccounts()` or any other wallet operations.
## Link External Wallet
Link an external wallet (such as MetaMask or a hardware wallet) to the user's Passport account. This requires a signature from the external wallet to prove ownership.
```typescript theme={null}
import { linkExternalWallet } from '@imtbl/wallet';
import type { LinkWalletParams, LinkedWallet } from '@imtbl/wallet';
const walletAddress = '0x...';
const nonce = 'unique-nonce-123';
const message = `Link wallet ${walletAddress} with nonce ${nonce}`;
// Sign the message with the external wallet (e.g., using ethers.js or viem)
const signature = await externalWallet.signMessage(message);
const params: LinkWalletParams = {
type: 'metamask',
walletAddress,
signature,
nonce,
};
const linkedWallet: LinkedWallet = await linkExternalWallet(
auth,
apiClient,
params
);
console.log('Linked wallet:', linkedWallet.address);
```
### Linking Errors
| Error Code | Description |
| -------------------- | ------------------------------------------- |
| `ALREADY_LINKED` | This wallet is already linked to an account |
| `MAX_WALLETS_LINKED` | Maximum number of linked wallets reached |
| `DUPLICATE_NONCE` | The nonce has already been used |
| `VALIDATION_ERROR` | Invalid signature or parameters |
## Check Balances
Native IMX and ERC-20 Token Balances
```typescript theme={null}
import { BrowserProvider, formatEther, Contract } from 'ethers';
const provider = connectWallet({ auth });
const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];
const balance = await provider.request({
method: 'eth_getBalance',
params: [address, 'latest']
});
console.log('IMX Balance:', formatEther(balance), 'IMX');
// Get ERC-20 token balance
const erc20Abi = [
'function balanceOf(address owner) view returns (uint256)',
'function decimals() view returns (uint8)',
'function symbol() view returns (string)',
];
const tokenAddress = '0x...'; // Your ERC-20 token contract address
const ethersProvider = new BrowserProvider(provider);
const tokenContract = new Contract(tokenAddress, erc20Abi, ethersProvider);
const tokenBalance = await tokenContract.balanceOf(address);
const decimals = await tokenContract.decimals();
const symbol = await tokenContract.symbol();
console.log(`Token Balance: ${formatEther(tokenBalance)} ${symbol}`);
```
```csharp theme={null}
// Get native IMX balance using Passport's zkEVM provider
string balance = await passport.ZkEvmGetBalance(playerAddress);
Debug.Log($"Balance: {balance} wei");
```
The `ZkEvmGetBalance` method returns balance in wei as a string.
```cpp theme={null}
// Add to your subsystem:
public:
void GetWalletBalance(const FString& WalletAddress)
{
auto Passport = GetPassport();
if (!Passport)
{
return;
}
FImmutablePassportZkEvmGetBalanceData BalanceData;
BalanceData.address = WalletAddress;
BalanceData.blockNumberOrTag = TEXT("latest"); // Optional: defaults to "latest"
Passport->ZkEvmGetBalance(BalanceData, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnBalanceReceived));
}
private:
void OnBalanceReceived(FImmutablePassportResult Result)
{
if (!Result.Success)
{
UE_LOG(LogTemp, Error, TEXT("Get Balance Failed: %s"), *Result.Error);
return;
}
FString Balance = UImmutablePassport::GetResponseResultAsString(Result.Response);
UE_LOG(LogTemp, Log, TEXT("Wallet Balance (wei): %s"), *Balance);
}
void OnWalletAccountsReceived(FImmutablePassportResult Result)
{
if (!Result.Success) return;
const auto& Data = FImmutablePassportZkEvmRequestAccountsData::FromJsonObject(Result.Response.JsonObject);
if (Data.IsSet() && Data->accounts.Num() > 0)
{
GetWalletBalance(Data->accounts[0]);
}
}
```
The `ZkEvmGetBalance` method returns balance in wei as a hex string.
## Send Transactions
**Wei Conversion:** 1 IMX = 10^18 wei. For TypeScript, use `parseUnits('1', 18)` from ethers or viem. Unity/Unreal require string values in wei.
### Send Transaction with Confirmation
**Recommended for critical operations** - Waits for blockchain confirmation before returning.
```typescript theme={null}
import { BrowserProvider } from 'ethers';
// Get the provider from Passport
const provider = await connectWallet({ auth });
// Wrap provider in ethers BrowserProvider
const browserProvider = new BrowserProvider(provider);
// Get the signer (represents the user's wallet)
const signer = await browserProvider.getSigner();
// Send transaction using signer
const tx = await signer.sendTransaction({
to: '0xRecipient...',
value: '1500000000000000000', // 1.5 IMX in wei
});
// Wait for blockchain confirmation
const receipt = await tx.wait();
console.log('Transaction confirmed!');
console.log('Hash:', receipt.hash);
console.log('Status:', receipt.status === 1 ? 'Success' : 'Failed');
```
```csharp theme={null}
using Immutable.Passport.Model;
TransactionRequest request = new TransactionRequest()
{
to = address,
value = amount,
data = data
}
TransactionReceiptResponse response = await passport.ZkEvmSendTransactionWithConfirmation(request);
switch (response.status)
{
case "1":
// Successful
break;
case "0":
// Failed
break;
}
```
```cpp theme={null}
// Add to your subsystem:
public:
void SendTransactionWithConfirmation(const FString& ToAddress, const FString& ValueInWei, const FString& Data = TEXT("0x"))
{
auto Passport = GetPassport();
if (!Passport)
{
UE_LOG(LogTemp, Error, TEXT("Passport not available"));
return;
}
FImtblTransactionRequest TransactionRequest;
TransactionRequest.to = ToAddress;
TransactionRequest.value = ValueInWei;
TransactionRequest.data = Data;
Passport->ZkEvmSendTransactionWithConfirmation(TransactionRequest, UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult Result)
{
if (Result.Success)
{
UE_LOG(LogTemp, Log, TEXT("Transaction Confirmed"));
}
else
{
UE_LOG(LogTemp, Error, TEXT("Transaction Confirmation Failed: %s"), *Result.Error);
}
}));
}
```
**When to use:** Critical operations like transfers, mints, or state changes. Ensures transaction succeeded before updating game state.
```typescript theme={null}
import { BrowserProvider, ethers } from 'ethers';
// Setup provider and signer
const provider = await connectWallet({ auth });
const browserProvider = new BrowserProvider(provider);
const signer = await browserProvider.getSigner();
// Define contract ABI (just the functions you need)
const abi = [
'function safeTransferFrom(address from, address to, uint256 tokenId)'
];
// Create contract instance
const contract = new ethers.Contract(
'0xYourNFTContract...', // Contract address
abi,
signer // Signer enables sending transactions
);
// Call contract function - automatically sends transaction
const tx = await contract.safeTransferFrom(
userAddress,
recipientAddress,
tokenId
);
// Wait for confirmation
const receipt = await tx.wait();
console.log('NFT transferred!', receipt.hash);
```
***
### Send Transaction without Confirmation
**For fire-and-forget operations** - Returns transaction hash immediately without waiting.
```typescript theme={null}
import { BrowserProvider } from 'ethers';
// Get the provider from Passport
const provider = await connectWallet({ auth });
// Wrap provider in ethers BrowserProvider
const browserProvider = new BrowserProvider(provider);
// Get the signer (represents the user's wallet)
const signer = await browserProvider.getSigner();
// Send transaction - returns immediately without waiting
const tx = await signer.sendTransaction({
to: '0xRecipient...',
value: '1500000000000000000', // 1.5 IMX in wei
});
console.log('Transaction sent:', tx.hash);
// User can continue - transaction confirms in background
```
**Fire-and-Forget:** Returns immediately without waiting for blockchain confirmation. Use for non-critical operations where you want responsive UI (cosmetic purchases, achievements, analytics).
```csharp theme={null}
using Cysharp.Threading.Tasks;
using Immutable.Passport.Model;
using System.Threading;
async void GetTransactionReceiptStatus()
{
TransactionRequest request = new TransactionRequest()
{
to = address,
value = amount,
data = data
}
string transactionHash = await passport.ZkEvmSendTransaction(request);
string? status = await PollStatus(
passport,
transactionHash,
TimeSpan.FromSeconds(1), // Poll every one second
TimeSpan.FromSeconds(10) // Stop polling after 10 seconds
);
switch (status)
{
case "0x1":
// Successful
break;
case "0x0":
// Failed
break;
}
}
static async UniTask PollStatus(Passport passport, string transactionHash, TimeSpan pollInterval, TimeSpan timeout)
{
var cancellationTokenSource = new CancellationTokenSource(timeout);
try
{
while (!cancellationTokenSource.Token.IsCancellationRequested)
{
TransactionReceiptResponse response = await passport.ZkEvmGetTransactionReceipt(transactionHash);
if (response.status == null)
{
// The transaction is still being processed, poll for status again
await UniTask.Delay(delayTimeSpan: pollInterval, cancellationToken: cancellationTokenSource.Token);
}
else
{
return response.status;
}
}
}
catch (OperationCanceledException)
{
// Task was canceled due to timeout
}
return null; // Timeout or could not get transaction receipt
}
```
```cpp theme={null}
// Add to your subsystem:
public:
void SendTransaction(const FString& ToAddress, const FString& ValueInWei, const FString& Data = TEXT("0x"))
{
auto Passport = GetPassport();
if (!Passport)
{
UE_LOG(LogTemp, Error, TEXT("SendTransaction: Passport not available"));
return;
}
FImtblTransactionRequest TransactionRequest;
TransactionRequest.to = ToAddress;
TransactionRequest.value = ValueInWei;
TransactionRequest.data = Data;
Passport->ZkEvmSendTransaction(TransactionRequest, UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult Result)
{
if (Result.Success)
{
FString TxHash = UImmutablePassport::GetResponseResultAsString(Result.Response);
UE_LOG(LogTemp, Log, TEXT("Transaction Hash: %s"), *TxHash);
}
else
{
UE_LOG(LogTemp, Error, TEXT("Send Transaction Failed: %s"), *Result.Error);
}
}));
}
```
**Important:** Don't update game state until transaction is confirmed. Use this pattern only for non-critical operations or when you have custom polling logic.
**Transaction Hash ≠ Success**: Obtaining the transaction hash does not guarantee a successful transaction. To determine the transaction's status, use `ZkEvmGetTransactionReceipt` along with the transaction hash received. Follow best practices for client-side polling: set maximum attempts, polling intervals, and implement timeout handling.
**When to use:** Non-critical operations or when you need custom polling/retry logic. Most games should use "with confirmation" variant for critical operations.
### NFT Transfer
```typescript theme={null}
const ERC721_ABI = [{
name: 'transferFrom',
type: 'function',
inputs: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
],
outputs: [],
stateMutability: 'nonpayable',
}] as const;
export async function transferNFT(
nftContract: `0x${string}`,
from: `0x${string}`,
to: `0x${string}`,
tokenId: bigint
) {
// highlight-start
const hash = await walletClient.writeContract({
account: from,
address: nftContract,
abi: ERC721_ABI,
functionName: 'transferFrom',
args: [from, to, tokenId],
});
// highlight-end
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log('NFT transferred! Block:', receipt.blockNumber);
return receipt;
}
```
```csharp theme={null}
public class NFTTransfer : MonoBehaviour
{
public async void Execute()
{
try
{
string receiverAddress = "0x..."; // Receiver's wallet address
string tokenId = "123"; // NFT Token ID
string tokenAddress = "0x..."; // NFT Contract Address
UnsignedTransferRequest transferRequest = UnsignedTransferRequest.ERC721(receiverAddress, tokenId, tokenAddress);
CreateTransferResponseV1 response = await Passport.Instance.ImxTransfer(transferRequest);
Debug.Log($"NFT transferred successfully! Transfer ID: {response.transfer_id}");
}
catch (System.Exception ex)
{
Debug.LogError($"Transfer failed: {ex.Message}");
}
}
}
```
```cpp theme={null}
// Add to your subsystem:
public:
void ImxTransferNFT(const FString& ReceiverAddress, const FString& TokenId, const FString& TokenAddress)
{
auto Passport = GetPassport();
if (!Passport)
{
UE_LOG(LogTemp, Error, TEXT("ImxTransfer: Passport not available"));
return;
}
FImxTransferRequest ImxTransfer;
ImxTransfer.receiver = ReceiverAddress;
ImxTransfer.type = TEXT("ERC721");
ImxTransfer.tokenId = TokenId;
ImxTransfer.tokenAddress = TokenAddress;
Passport->ImxTransfer(ImxTransfer, UImmutablePassport::FImtblPassportResponseDelegate::CreateWeakLambda(this, [this](FImmutablePassportResult Result)
{
if (Result.Success)
{
UE_LOG(LogTemp, Log, TEXT("IMX NFT Transfer Successful"));
}
else
{
UE_LOG(LogTemp, Error, TEXT("IMX NFT Transfer Failed: %s"), *Result.Error);
}
}));
}
```
## Sign Messages
### Personal Sign (ERC-191)
For authentication or simple message signing:
```typescript theme={null}
// Assuming you have the Passport provider from connectEvm()
const provider = connectWallet({ auth });
const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];
const message = 'Hello from Immutable!';
// Sign the message using personal_sign (ERC-191)
const signature = await provider.request({
method: 'personal_sign',
params: [message, address],
});
console.log('Signature:', signature);
```
**Unity SDK Limitation:** Unity SDK does not support ERC-191 personal sign. Only EIP-712 typed data signing is supported via `ZkEvmSignTypedDataV4()`. See the Typed Data section below for message signing in Unity.
**Unreal SDK Limitation:** Unreal SDK does not support ERC-191 personal sign. Only EIP-712 typed data signing is supported via `ZkEvmSignTypedDataV4()`. See the Typed Data section below for message signing in Unreal.
### Typed Data (EIP-712)
For structured data signing (used by protocols like Seaport):
```typescript theme={null}
// Assuming you have the Passport provider from connectEvm()
const provider = connectWallet({ auth });
const accounts = await provider.request({ method: 'eth_requestAccounts' });
const address = accounts[0];
// Get chain ID
const chainIdHex = await provider.request({ method: 'eth_chainId' });
const chainId = parseInt(chainIdHex, 16);
// Define EIP-712 typed data structure
const typedData = {
domain: {
name: 'My Game',
version: '1',
chainId,
verifyingContract: address, // Your contract address
},
message: {
itemId: 123,
price: '1000000000000000000', // 1 token in wei
},
primaryType: 'Trade',
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Trade: [
{ name: 'itemId', type: 'uint256' },
{ name: 'price', type: 'uint256' },
],
},
};
// Sign typed data using eth_signTypedData_v4
const signature = await provider.request({
method: 'eth_signTypedData_v4',
params: [address, JSON.stringify(typedData)],
});
console.log('Typed data signature:', signature);
```
```csharp theme={null}
// Construct EIP-712 typed data as JSON string
string typedDataJson = @"{
""domain"": {
""name"": ""My Game"",
""version"": ""1"",
""chainId"": 13473,
""verifyingContract"": ""0xYourContract...""
},
""types"": {
""Trade"": [
{ ""name"": ""itemId"", ""type"": ""uint256"" },
{ ""name"": ""price"", ""type"": ""uint256"" }
]
},
""primaryType"": ""Trade"",
""message"": {
""itemId"": 123,
""price"": ""10000000000000000000""
}
}";
var signature = await passport.ZkEvmSignTypedDataV4(typedDataJson);
Debug.Log($"Signature: {signature}");
```
```cpp theme={null}
// Add to your subsystem:
public:
void SignTypedData()
{
auto Passport = GetPassport();
if (!Passport)
{
return;
}
FString TypedDataJson = TEXT(R"({
"domain": {
"name": "My Game",
"version": "1",
"chainId": 13473,
"verifyingContract": "0xYourContract..."
},
"types": {
"Trade": [
{ "name": "itemId", "type": "uint256" },
{ "name": "price", "type": "uint256" }
]
},
"primaryType": "Trade",
"message": {
"itemId": "123",
"price": "10000000000000000000"
}
})");
Passport->ZkEvmSignTypedDataV4(TypedDataJson, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnTypedDataSigned));
}
private:
void OnTypedDataSigned(FImmutablePassportResult Result)
{
if (!Result.Success)
{
UE_LOG(LogTemp, Error, TEXT("Sign Typed Data Failed: %s"), *Result.Error);
return;
}
FString Signature = UImmutablePassport::GetResponseResultAsString(Result.Response);
UE_LOG(LogTemp, Log, TEXT("Signature: %s"), *Signature);
}
// Call when user needs to sign typed data:
SignTypedData();
```
## Provider Events
The provider emits standard [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193) events that you can subscribe to:
```typescript theme={null}
provider.on('accountsChanged', (accounts: string[]) => {
console.log('Accounts changed:', accounts);
});
provider.on('chainChanged', (chainId: string) => {
console.log('Chain changed:', chainId);
});
provider.on('disconnect', (error: Error) => {
console.log('Disconnected:', error);
});
// Remove a specific listener
provider.removeListener('accountsChanged', handler);
```
## EIP-6963 Provider Discovery
The wallet automatically announces itself via [EIP-6963](https://eips.ethereum.org/EIPS/eip-6963) for wallet discovery by dApps. Disable this if needed:
```typescript theme={null}
const provider = await connectWallet({
announceProvider: false,
});
```
For manual announcement:
```typescript theme={null}
import { announceProvider, passportProviderInfo } from '@imtbl/wallet';
announceProvider({
info: passportProviderInfo,
provider: yourProvider,
});
```
## Error Handling
Use `WalletError` and `WalletErrorType` for typed error handling:
```typescript theme={null}
import { connectWallet, WalletError, WalletErrorType } from '@imtbl/wallet';
try {
const provider = await connectWallet();
const accounts = await provider.request({ method: 'eth_requestAccounts' });
} catch (error) {
if (error instanceof WalletError) {
switch (error.type) {
case WalletErrorType.NOT_LOGGED_IN_ERROR:
console.log('User is not logged in');
break;
case WalletErrorType.WALLET_CONNECTION_ERROR:
console.log('Failed to connect wallet:', error.message);
break;
case WalletErrorType.TRANSACTION_REJECTED:
console.log('User rejected the transaction');
break;
case WalletErrorType.UNAUTHORIZED:
console.log('Unauthorized - call eth_requestAccounts first');
break;
case WalletErrorType.GUARDIAN_ERROR:
console.log('Guardian validation failed');
break;
case WalletErrorType.INVALID_CONFIGURATION:
console.log('Invalid wallet configuration');
break;
case WalletErrorType.SERVICE_UNAVAILABLE_ERROR:
console.log('Service temporarily unavailable');
break;
default:
console.error('Wallet error:', error.message);
}
} else {
console.error('Unexpected error:', error);
}
}
```
### Error Types
| Error Type | Description |
| --------------------------- | ------------------------------------- |
| `NOT_LOGGED_IN_ERROR` | User is not authenticated |
| `WALLET_CONNECTION_ERROR` | Failed to connect or link wallet |
| `TRANSACTION_REJECTED` | User rejected a transaction |
| `UNAUTHORIZED` | Operation requires authentication |
| `GUARDIAN_ERROR` | Guardian (security) validation failed |
| `INVALID_CONFIGURATION` | Invalid wallet configuration |
| `SERVICE_UNAVAILABLE_ERROR` | Backend service unavailable |
### JSON-RPC Error Codes
```typescript theme={null}
import { ProviderErrorCode, RpcErrorCode } from '@imtbl/wallet';
// Standard provider error codes (EIP-1193)
ProviderErrorCode.USER_REJECTED_REQUEST // 4001
ProviderErrorCode.UNAUTHORIZED // 4100
ProviderErrorCode.UNSUPPORTED_METHOD // 4200
ProviderErrorCode.DISCONNECTED // 4900
// Standard RPC error codes
RpcErrorCode.INVALID_REQUEST // -32600
RpcErrorCode.METHOD_NOT_FOUND // -32601
RpcErrorCode.INVALID_PARAMS // -32602
RpcErrorCode.INTERNAL_ERROR // -32603
```
```csharp theme={null}
try
{
var response = await passport.ZkEvmSendTransactionWithConfirmation(request);
Debug.Log($"Success: {response.transactionHash}");
}
catch (Exception e)
{
Debug.LogError($"Transaction failed: {e.Message}");
}
```
```cpp theme={null}
// Add to your subsystem:
public:
void SendTransactionWithErrorHandling(const FString& ToAddress, const FString& Data)
{
auto Passport = GetPassport();
if (!Passport)
{
UE_LOG(LogTemp, Error, TEXT("Failed to get Passport instance"));
return;
}
FImtblTransactionRequest Request;
Request.to = ToAddress;
Request.data = Data;
Request.value = TEXT("0");
Passport->ZkEvmSendTransactionWithConfirmation(Request, UImmutablePassport::FImtblPassportResponseDelegate::CreateUObject(this, &UPassportQuickStartSubsystem::OnTransactionComplete));
}
private:
void OnTransactionComplete(FImmutablePassportResult Result)
{
if (!Result.Success)
{
// Handle different error cases
if (Result.Error.Contains(TEXT("User rejected")))
{
UE_LOG(LogTemp, Warning, TEXT("User rejected the transaction"));
}
else if (Result.Error.Contains(TEXT("insufficient funds")))
{
UE_LOG(LogTemp, Error, TEXT("Insufficient balance"));
}
else
{
UE_LOG(LogTemp, Error, TEXT("Transaction error: %s"), *Result.Error);
}
return;
}
TOptional Receipt = JsonObjectToUStruct(Result.Response.JsonObject);
if (!Receipt.IsSet())
{
UE_LOG(LogTemp, Error, TEXT("Failed to parse transaction receipt"));
return;
}
UE_LOG(LogTemp, Log, TEXT("Transaction Hash: %s"), *Receipt->hash);
UE_LOG(LogTemp, Log, TEXT("Status: %s"), *Receipt->status);
}
```
## Next Steps
Gas sponsorship and transaction costs
Instant transactions without popups (Unity/Unreal)
Where users manage their wallet
Understand the security model
Mint NFTs to user wallets
Contract allowlisting and verification
# Play
Source: https://docs.immutable.com/docs/products/play/overview
Game discovery and wallet management for players
Discover games, complete quests, and manage your wallet
Immutable Play is the player-facing portal where users discover games, complete quests, and manage their Passport wallet. Direct your players here to manage their assets and engage with the broader Immutable ecosystem.
## Features
Players browse trending and new games built on Immutable. Games with active quests appear prominently, driving organic discovery for your title.
Players complete quests across multiple games in one place. Quests you create through [Questing](/docs/products/play/questing) appear here for players to discover and complete.
The primary interface for players to view token balances, transaction history, and manage their Passport wallet.
Players view their NFT collections, browse assets, and trade on the marketplace—all without leaving the platform.
## Wallet
The **Wallet** tab is the primary interface for Passport wallet management. Players can:
* View balances across tokens (ETH, IMX, and game tokens)
* Send and receive tokens
* Review transaction history
* Fund their wallet
When building wallet-related features in your game, consider linking to [play.immutable.com](https://play.immutable.com) for advanced wallet management rather than rebuilding these features yourself.
## Inventory
The **Inventory** tab displays all NFT collections owned by the player. Features include:
* Browse collections by game
* View individual asset details and metadata
* List assets for sale on the marketplace
* Transfer assets to other wallets
## Quests & Discovery
When you create quests using [Questing](/docs/products/play/questing), they become visible on Immutable Play. This provides:
* **Organic discovery** — Players browsing Play find your game through active quests
* **Centralised tracking** — Players track quest progress across all their games
* **Ecosystem engagement** — Your game benefits from cross-promotion with other titles
## For Developers
Immutable Play is player-facing, but understanding it helps you build better experiences:
| Instead of... | Direct players to Play |
| ----------------------------------- | -------------------------------------- |
| Building a full wallet UI | Link to Play for balance/history views |
| Creating an NFT browser | Link to Play for collection management |
| Building a marketplace from scratch | Use Play's trading features |
Your game still handles authentication, transactions, and gameplay. Play complements your game by providing wallet and asset management players expect.
## Next Steps
Create quests that appear on Play
Integrate authentication in your game
# Questing
Source: https://docs.immutable.com/docs/products/play/questing
Keep players engaged with in-game and pre-launch quest systems
Quests keep players engaged throughout your game's lifecycle. Players complete objectives, earn rewards, and climb leaderboards—whether your game is pre-launch or live.
## Overview
The Audience Builder Program includes in-game quests hosted on your game's Audience Builder page in [Immutable Play](https://play.immutable.com). Players sign up using [Immutable Passport](/docs/products/passport/overview) (simple social sign-on) to:
* Access the Immutable Play platform, game quests, and leaderboard competitions
* Track questing and leaderboard progress
* Receive rewards
* Link social accounts to their Immutable Play account
**No Passport integration required in-game.** Your game doesn't need to implement Immutable Passport as its login/wallet system or integrate any Immutable chain infrastructure. Simply track player progress using your existing backend and notify Immutable when a quest is completed via a lightweight API call.
## Responsibilities
| Task | Owner | Description |
| -------------------------------- | ------------------ | -------------------------------------------------------------------------- |
| **Campaign & quest design** | Studio + Immutable | Collaborate to define quest structure, reward types, and timing |
| **Player onboarding & linking** | Immutable | Players link an identifier matched to your backend during onboarding |
| **Game login & player tracking** | Studio | Use your existing login and backend systems |
| **Quest completion submission** | Studio | Track quest progress in-game and send completions to Immutable's Quest API |
| **Reward matching & delivery** | Immutable | Match identifier to Passport and deliver rewards |
## How to Integrate
Work with Immutable to agree on quests (e.g., "Play 30 arena matches") and their rewards
Create an API key on [Immutable Hub](/docs/guides/advanced-setup/api-keys) and share your Org ID
Use your existing backend to monitor when players meet quest criteria
When a quest is completed, send a POST request to the Immutable Quest API with the player's linked identifier
Immutable handles player matching and instantly updates their Play inventory
## Supported Identifiers
Players can link one of these accounts to their Passport. Use this identifier when submitting quest completions.
| Account Type | Account ID | Example |
| ------------ | ----------------------- | -------------------------------------------- |
| `telegram` | Telegram user ID | `1191097385` |
| `discord` | Discord user ID | `1374611107196440770` |
| `email` | Email address | `user@example.com` |
| `metamask` | MetaMask wallet address | `0xc257274276a4e539741ca11b590b9447b26a8051` |
| `passport` | Passport wallet address | `0xa9361c0dd68e1c3a69b43f646133fdab8d3859a2` |
| `epic_games` | Epic Games user ID | `a26eca91eca340a7a8cadb886e7c1190` |
**Notes on account types:**
* `email` is not supported in Sandbox. Use another account type for testing.
* `epic_games` account linking is not yet supported in Immutable Play. However, quest completions with `epic_games` will be stored and credited once linking is available—no progress will be lost.
***
## Quest Types
Track gameplay actions: matches played, levels completed, items collected
Follow on Twitter, join Discord, share content, engage with posts
Invite friends, reach referral milestones, build your network
Verify blockchain actions: first trade, first mint, collection completion
## Leaderboards
Gamify your community with competitive rankings:
| Leaderboard | Criteria | Rewards |
| ------------ | ------------------- | --------------------------------- |
| **All-Time** | Total points earned | Founder badges, exclusive items |
| **Weekly** | Points this week | Featured spotlight, bonus rewards |
| **Referral** | Friends referred | VIP access, revenue share |
Players can spend gems to showcase their username on leaderboards across Immutable Play.
***
## Questing API
The Questing API provides endpoints for recording quest completions, retrieving active quests, and fetching player earnings. Use these endpoints to integrate quest functionality into your game backend.
### Prerequisites
* **Immutable API Key**: [Create one in Immutable Hub](/docs/guides/advanced-setup/api-keys)
* **Game ID**: Provided during onboarding
### Required Quest: Login and Play Game
As part of the Quest API integration, partners are required to implement the **Login and Play Game** quest, identified by the `login-play-game` `external_id`.
This quest is configured as a **daily recurring quest** and should be triggered every time a player logs in. While triggered on login, it tracks participation on a daily basis, providing valuable insights into player engagement and recurring behavior patterns.
```json theme={null}
{
"external_id": "login-play-game",
"account_type": "discord",
"account_id": "901290221440733204"
}
```
### Important Notes
**Quest completions are stored for non-Passport users.** If the identified user is not yet a registered Immutable Passport user, the quest completion will be stored with a reference to the account type and ID supplied in the request. When the user registers with Immutable Passport and links that account, they will be instantly rewarded and credited for this quest (and any other stored completions). This means user accounts do not need to be current Passport users when making requests.
**Always use the `reference` field for idempotency.** We highly recommend using a unique `reference` field (e.g., `{accountId}-{externalId}-{timestamp}`) to ensure the same user cannot complete the same quest twice. If you receive a `409 Conflict` response, the quest completion was already recorded—don't retry.
### API Endpoints
The Questing API provides three main endpoints:
* **Quest Completions** — Record quest completions and trigger rewards (requires authentication)
* **Get Live Quests** — Retrieve all currently active quests for your game (no authentication required)
* **Latest Earnings** — Fetch player gem earning history by wallet address (no authentication required)
Complete API documentation for all endpoints, including request/response schemas, authentication details, and code examples, is available in the API Reference tab.
***
## Best Practices
Follow these best practices to ensure reliable quest completion tracking and optimal integration with the Quest API.
### Idempotency
Always use the `reference` field to prevent duplicate quest completions.
**Recommended Pattern**
Use a unique reference string that combines player identifier, quest ID, and timestamp:
```typescript theme={null}
const reference = `${accountId}-${externalId}-${Date.now()}`;
```
**Why It Matters**
* Prevents accidental duplicate submissions
* Enables safe retries on network failures
* Provides audit trail for debugging
If you receive a `409 Conflict` response, the quest completion was already recorded. Don't retry the request—treat it as success.
### Error Handling
Implement robust error handling with appropriate retry logic.
**Retry Strategy**
```typescript theme={null}
async function submitQuestCompletionWithRetry(
gameId: string,
apiKey: string,
externalId: string,
accountType: string,
accountId: string,
reference: string,
maxRetries = 3
) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(
`https://api.immutable.com/quests/v2/games/${gameId}/quest-completions`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-immutable-api-key': apiKey,
},
body: JSON.stringify({
external_id: externalId,
account_type: accountType,
account_id: accountId,
reference,
completion_time: new Date().toISOString(),
}),
}
);
// Success - return immediately
if (response.ok) {
return await response.json();
}
// 409 Conflict - already recorded, treat as success
if (response.status === 409) {
console.log(`Quest completion already recorded: ${reference}`);
return { result: { status: 'already_completed' } };
}
// 4xx errors - don't retry (client error)
if (response.status >= 400 && response.status < 500) {
const error = await response.json();
throw new Error(`Client error: ${error.message || response.statusText}`);
}
// 5xx errors - retry with exponential backoff
if (response.status >= 500) {
if (attempt < maxRetries) {
const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw new Error(`Server error after ${maxRetries} attempts`);
}
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
// Network errors - retry with exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
```
**Error Response Handling**
| Status Code | Action |
| -------------------------- | ------------------------------------------------ |
| `200` | Success - quest completion recorded |
| `409` | Already recorded - treat as success, don't retry |
| `400`, `401`, `403`, `404` | Client error - don't retry, log and investigate |
| `500`, `504` | Server error - retry with exponential backoff |
### Logging
Always log quest completion requests for debugging and auditing.
**What to Log**
```typescript theme={null}
function logQuestCompletion(
externalId: string,
accountType: string,
accountId: string,
reference: string,
status: 'success' | 'error',
error?: string
) {
const logEntry = {
timestamp: new Date().toISOString(),
quest: externalId,
account: { type: accountType, id: accountId },
reference,
status,
error,
};
// Log to your preferred logging service
console.log(JSON.stringify(logEntry));
// Optionally send to monitoring service
// sendToMonitoring(logEntry);
}
```
**Logging Best Practices**
* Log before making the API request
* Log the response status and any errors
* Include the reference ID for correlation
* Don't log sensitive data (mask account IDs if needed)
* Use structured logging for easier querying
### Rate Limiting
The Quest API implements rate limiting to ensure fair usage.
**Rate Limit Tiers**
| Tier | Rate Limit |
| -------------- | ---------- |
| **Standard** | 50 req/sec |
| **Enterprise** | Custom |
**Default rate limits:**
* Global default: 5 requests per second per public IP
* Partner adjusted rate limit: 50 requests per second per API key
* Enterprise: Custom rate limits based on your agreement
**Recommendations**
* **Batch quest completions** when possible (if multiple quests complete simultaneously)
* **Implement client-side rate limiting** to avoid hitting limits
* **Use exponential backoff** when receiving rate limit errors (typically `429` status)
* **Monitor your API usage** in [Immutable Hub](https://hub.immutable.com) — see [API Keys](/docs/guides/advanced-setup/api-keys) for details
**Handling Rate Limits**
```typescript theme={null}
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const delay = retryAfter ? parseInt(retryAfter) * 1000 : 60000; // Default 60s
await new Promise(resolve => setTimeout(resolve, delay));
// Retry the request
}
```
### Account ID Validation
Validate account IDs before submitting to ensure correct format.
**Validation Examples**
```typescript theme={null}
function validateAccountId(accountType: string, accountId: string): boolean {
switch (accountType) {
case 'discord':
// Discord IDs are numeric strings (snowflakes)
return /^\d+$/.test(accountId);
case 'telegram':
// Telegram IDs are numeric
return /^\d+$/.test(accountId);
case 'metamask':
case 'passport':
// Wallet addresses are hex strings starting with 0x
return /^0x[a-fA-F0-9]{40}$/.test(accountId);
case 'email':
// Basic email validation
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(accountId);
case 'epic_games':
// Epic Games IDs are alphanumeric strings
return /^[a-zA-Z0-9]+$/.test(accountId);
default:
return false;
}
}
```
### Async Processing
Consider processing quest completions asynchronously to avoid blocking game logic.
**Queue-Based Approach**
```typescript theme={null}
// Add to queue instead of blocking
questCompletionQueue.add({
externalId: 'catch-3-fish',
accountType: 'discord',
accountId: playerDiscordId,
reference: generateReference(),
});
// Process queue in background
async function processQuestQueue() {
while (true) {
const item = await questCompletionQueue.get();
try {
await submitQuestCompletion(item);
} catch (error) {
// Log error and potentially re-queue
logError(error);
}
}
}
```
### Testing
Test your integration thoroughly before going live.
**Test Scenarios**
1. **Successful completion** - Verify quest is recorded correctly
2. **Duplicate submission** - Ensure idempotency works with same reference
3. **Invalid account ID** - Verify proper error handling
4. **Network failures** - Test retry logic
5. **Rate limiting** - Verify backoff behavior
6. **Required quest** - Ensure `login-play-game` is triggered on login
**Sandbox Testing**
* Use Sandbox environment for all testing
* Test with `discord`, `telegram`, or `metamask` account types (not `email`)
* Verify quest completions appear in Immutable Play (Sandbox)
### Monitoring
Set up monitoring and alerts for quest completion failures.
**Key Metrics to Track**
* Quest completion success rate
* API error rates by status code
* Average response time
* Rate limit hits
* Duplicate completion attempts
**Alert Thresholds**
* Error rate > 5%
* Response time > 2 seconds
* Rate limit errors detected
***
## FAQ
No. The Quest API is designed to work with your existing authentication system. Players link their accounts (Discord, Telegram, email, etc.) to [Immutable Passport](/docs/products/passport/overview) through Immutable Play, but your game doesn't need to implement Passport authentication.
Quest completions are stored retroactively. When a player registers with [Immutable Passport](/docs/products/passport/overview) and links their account (Discord, Telegram, etc.), they'll automatically receive credit for all previously completed quests. Quest completions are stored with the account type and ID you provide—when the player eventually links that account to Passport, they'll receive all stored rewards.
Yes. Quest completions are stored with the account type and ID you provide. When the player eventually links that account to [Immutable Passport](/docs/products/passport/overview), they'll receive all stored rewards.
The `login-play-game` quest is a required daily recurring quest that tracks player engagement. You must trigger this quest every time a player logs into your game. It provides valuable data on daily active users and recurring behavior patterns.
Always include a unique `reference` field in your quest completion requests. Use a pattern like `{accountId}-{externalId}-{timestamp}`. If you receive a `409 Conflict` response, the quest was already recorded—don't retry.
Supported account types are: `telegram`, `discord`, `email`, `metamask`, `passport`, and `epic_games`. Note that `email` is not supported in Sandbox—use `discord`, `telegram`, or `metamask` for testing.
**Important:** `metamask` and other third-party wallets need to be linked by the user either through the Play profile settings or a custom UX built by the studio using the [link wallet API/SDK functionality](/docs/products/passport/wallet#linked-addresses).
Your Game ID is provided by Immutable's Growth team during onboarding. Contact them if you don't have it yet.
A `409 Conflict` means the quest completion was already recorded (likely due to a duplicate `reference`). Treat this as success—don't retry the request.
* **4xx errors (400, 401, 403)**: Don't retry—these indicate client errors that need investigation
* **404 Not Found**: Don't retry—see common causes below
* **409 Conflict**: Treat as success—quest already recorded
* **429 Rate Limit**: Wait for the `Retry-After` header duration, then retry
* **5xx errors (500, 504)**: Retry with exponential backoff
A `404 Not Found` error can occur when:
* The quest doesn't exist (check the `external_id` is correct)
* The quest has expired (check `valid_from` and `valid_to` dates)
* The request was made to a production endpoint with a sandbox quest ID (or vice versa)
Verify you're using the correct environment (Sandbox vs Production) and that the quest is active. If the issue persists, contact your Growth team.
The Quest API accepts one quest completion per request. If multiple quests complete simultaneously, submit them sequentially. Consider using a queue system to handle high-volume scenarios.
Use the Sandbox environment (`https://api.sandbox.immutable.com`) for testing. Test with `discord`, `telegram`, or `metamask` account types. Verify quest completions appear in Immutable Play (Sandbox).
Additionally, studios can:
* Visit [play.sandbox.immutable.com](https://play.sandbox.immutable.com) to see quest and reward status
* Use the Live Quests endpoint (`GET /v1/quests/games/{game_id}/quests`) to see the number of quest completions
The `completion_time` field allows you to specify when the quest was actually completed (in RFC3339 UTC format). This is useful for retroactive attribution or batch processing. If omitted, the API uses the current time.
Rewards are typically delivered instantly when a quest completion is successfully recorded. If a player hasn't linked their account yet, rewards are stored and delivered when they link their account to Passport.
Quest completion data is visible in Immutable Play. Players can see their quest progress and rewards in their Play inventory. For analytics, you can view quest completion data via the [Immutable Hub Insights feature](https://hub.immutable.com). The Insights feature is enabled with an Immutable Audience Builder subscription. You can also contact Immutable's Growth team for additional analytics.
Quest definitions are managed in collaboration with Immutable's Growth team. Contact them to discuss changes to quest structure, rewards, or timing.
Yes, the Quest API implements rate limiting to ensure fair usage:
* **Standard tier**: 50 requests per second per API key
* **Global default**: 5 requests per second per public IP
* **Enterprise**: Custom rate limits based on your agreement
If you receive a `429 Too Many Requests` response, implement exponential backoff and retry after the duration specified in the `Retry-After` header.
**Common causes of 429 errors:**
* The API key is not being used correctly in the request flow
* Sub-optimal integration where too many requests are made than needed
* Increase in base traffic (which is a great sign for the project)
If you experience persistent 429 errors, contact your Growth team to review your integration and discuss potential optimizations or rate limit adjustments.
Yes, if your API key has the necessary permissions. However, ensure your key has access to the Quest API endpoints for your game.
Contact Immutable support through [support.immutable.com](https://support.immutable.com) or reach out to your Growth team contact for Quest API-specific questions.
***
Create your API key in Immutable Hub
Get help setting up your quest campaign
## Next Steps
Learn about the full Audience suite
Set up your pre-launch landing page
Configure quest completion campaigns
Track quest performance data
# Weekly Rewards
Source: https://docs.immutable.com/docs/products/play/weekly-rewards
Earn IMX tokens, bonus Gems, and exclusive prizes every week
The Weekly Rewards program on Immutable Play gives you the chance to earn IMX tokens, bonus Gems, and exclusive prizes every week. By completing quests and engaging with games, you can participate in Weekly Draws and compete for rewards.
The Weekly Rewards program is sponsored by the IMX Ecosystem Foundation and operated by Immutable Play.
## How it works
The Weekly Rewards program is built around a weekly cycle:
Complete quests, follow games, and claim daily rewards to collect Gems
Exchange 500 Gems for a Key that enters you into the Weekly Draw
Complete eligible quests to earn Weekly Points and upgrade your Key tier
Receive IMX tokens, bonus Gems, or free entries when the draw concludes
## Key concepts
| Concept | Description |
| ------------------ | ----------------------------------------------------------------------- |
| **Gems** | The currency you earn through gameplay activities on Immutable Play |
| **Keys** | Entry tickets for the Weekly Draw, obtained by redeeming 500 Gems |
| **Key Tiers** | Five levels (Common to Legendary) that determine your prize pool access |
| **Weekly Draw** | The weekly event where rewards are distributed to participants |
| **Mythic Rewards** | Top-tier IMX prizes awarded to select participants each week |
## Eligibility requirements
To participate in the Weekly Rewards program, you need:
* **Immutable Passport wallet** - The program is only available to Passport users
* **Age requirement** - You must be 18 years or older
* **Regional availability** - The program is available worldwide, except where prohibited by law
## Getting started
Ready to start earning rewards? Follow these steps:
Visit [play.immutable.com](https://play.immutable.com) with your Passport account
Complete quests and daily activities to collect Gems
Redeem 500 Gems for a Key
Increase your Key tier by earning Weekly Points
Collect your prizes when the draw concludes
# Game Bridge Architecture
Source: https://docs.immutable.com/docs/sdks/game-bridge-architecture
Learn how Immutable game SDKs work internally and how to adapt them for custom engines.
This page is primarily for developers building custom engine integrations or wanting to understand the SDK architecture. If you're using Unity or Unreal, refer to their respective SDK documentation.
## Overview
The Unity and Unreal SDKs share a common architecture that bridges native game engines with TypeScript-based functionality. This design allows consistent behavior across platforms while leveraging platform-specific capabilities.
The architecture has three main components: a **game bridge** for communication, an **invisible WebView** for executing TypeScript, and an **in-app browser** for secure authentication.
## Architecture Diagrams
### High-Level Architecture
```mermaid theme={null}
graph TB
Game["Game"]
subgraph GameSDK["Game SDK"]
direction TB
subgraph InvisibleWebView["Invisible WebView"]
direction TB
subgraph IndexFile["index file"]
direction TB
GameBridge["game-bridge"]
RequiredSDK["Required Immutable TypeScript SDK packages"]
end
end
end
Game --> GameSDK
```
### Communication Flow
How the game engine communicates with TypeScript SDK packages through JSON serialization:
```mermaid theme={null}
graph LR
Game["Game Engine"]
SDK["Game SDK"]
WebView["Invisible WebView"]
IndexFile["Game Bridge"]
TSPackage["TypeScript SDK"]
Game <-->|"function call"| SDK
SDK <-->|"JSON serialize/ deserialize"| WebView
WebView <-->|"callFunction() parse JSON"| IndexFile
IndexFile <-->|"callFunction() callbackToGame()"| TSPackage
```
### PKCE Authentication Flow
How authentication works using in-app browsers for security:
```mermaid theme={null}
sequenceDiagram
participant Game
participant SDK as Game SDK
participant Native as Native Code
participant Browser as In-App Browser
Game->>SDK: Login via PKCE flow
SDK->>Native: Launch in-app browser
Native->>Browser: Open authentication URL
Browser->>Browser: User authenticates
Browser->>Native: Deep link callback
Native->>SDK: Authentication complete
SDK->>Game: Login success
```
## Core Components
### 1. Game Bridge
The game bridge enables string-based communication between your game engine and the [TypeScript SDK](/docs/sdks/typescript).
**How it works:**
* The [`game-bridge`](https://github.com/immutable/ts-immutable-sdk/tree/main/packages/game-bridge) package enables communication with TypeScript SDK packages
* TypeScript packages are bundled into a single file loaded into an invisible WebView
* The game SDK sends JSON string messages to the WebView and receives responses
**Implementation:**
* Unity: [`index.html`](https://github.com/immutable/unity-immutable-sdk/blob/main/src/Packages/Passport/Runtime/Resources/index.html)
* Unreal: [`index.js`](https://github.com/immutable/unreal-immutable-sdk/blob/main/Web/index.js)
Both contain the bundled TypeScript SDK and `game-bridge` package.
### 2. WebView
An invisible WebView loads the game bridge and executes TypeScript SDK functionality.
**Requirements:**
* HTML/JavaScript loading support
* Modern JavaScript (ES6+)
* Bidirectional native-JavaScript communication
* Hidden from user view (no UI rendering)
**Platform considerations:**
* Mobile: Native WebView components (Android/iOS)
* Desktop: May require embedded browser frameworks
* Windows: CEF 90+ required
### 3. In-App Browser
Authentication uses an in-app browser (not WebView) for security and SSO compatibility.
**Requirements:**
* Secure, isolated browser context
* Process isolation (runs separately from your app)
* Deep link callback handling
* System authentication session support
Never use WebView for authentication. In-app browsers run in separate processes, preventing credential theft and meeting OAuth 2.0 best practices. See [Why in-app browser?](/docs/sdks/unity/faq) for details.
For platform-specific implementations, see:
* [Unity SDK Repository](https://github.com/immutable/unity-immutable-sdk)
* [Unreal SDK Repository](https://github.com/immutable/unreal-immutable-sdk)
## Adapting for Custom Engines
This section is for custom game engine integrations. Unity and Unreal users don't need to modify these components.
To adapt the SDK for your custom engine, you'll need to modify three areas:
### 1. Modify the Game Bridge
Keep the bundled TypeScript SDK but modify the `callbackToGame()` function in [`game-bridge/index.ts`](https://github.com/immutable/ts-immutable-sdk/blob/main/packages/game-bridge/src/index.ts) to communicate with your engine.
After modification, rebuild the bundle following the [game-bridge README](https://github.com/immutable/ts-immutable-sdk/blob/main/packages/game-bridge/README.md).
### 2. Implement WebView Communication
**Option A:** Reuse our platform-native WebView implementations and adapt the communication layer
**Option B:** Implement a custom WebView with bidirectional string communication and lifecycle management
**Reference implementation:**
* C# engines: [Unity SDK](https://github.com/immutable/unity-immutable-sdk)
* C++ engines: [Unreal SDK](https://github.com/immutable/unreal-immutable-sdk)
### 3. Implement PKCE Authentication
Reuse our platform-native PKCE implementations and deep linking handlers. Adapt the communication layer between your engine and native code for URL scheme registration and callback handling.
The Game SDK is lightweight—essentially a wrapper around the TypeScript SDK. Most complexity is in the bridge and WebView communication, not the SDK logic itself.
## Related Documentation
Unity SDK implementation
Unreal SDK implementation
Core TypeScript packages
Game bridge source code
Common questions about SDK architecture
# FAQ
Source: https://docs.immutable.com/docs/sdks/typescript/faq
# Frequently Asked Questions
Common questions and solutions for build issues, authentication, transactions, and debugging with the TypeScript SDK.
## Build & Environment
Modern bundlers don't include Node.js polyfills by default.
**Vite:**
```bash theme={null}
npm install vite-plugin-node-polyfills
```
```typescript theme={null}
// vite.config.ts
import { nodePolyfills } from 'vite-plugin-node-polyfills';
export default defineConfig({
plugins: [
nodePolyfills({
include: ['buffer', 'crypto', 'stream', 'util'],
}),
],
});
```
**Next.js / Create React App:**
```typescript theme={null}
// Add to top of your entry file
import { Buffer } from 'buffer';
if (typeof window !== 'undefined') {
window.Buffer = Buffer;
}
```
Add global polyfill to your bundler config:
```typescript theme={null}
// vite.config.ts
export default defineConfig({
define: {
global: 'globalThis',
},
});
```
Ensure `tsconfig.json` uses modern module resolution:
```json theme={null}
{
"compilerOptions": {
"moduleResolution": "bundler", // or "node16"
"esModuleInterop": true
}
}
```
Import from modular packages instead of the umbrella SDK:
```typescript theme={null}
// ❌ May cause issues with App Router
import { config, auth } from '@imtbl/sdk';
// ✅ Works correctly - use modular packages
import { Auth } from '@imtbl/auth';
import { connectWallet } from '@imtbl/wallet';
import { Environment } from '@imtbl/config';
```
If you see elliptic package errors, add to `next.config.js`:
```javascript theme={null}
const nextConfig = {
experimental: {
esmExternals: false,
},
};
```
Import modular packages instead of the umbrella package:
```typescript theme={null}
// ❌ Imports everything
import { auth, orderbook } from '@imtbl/sdk';
// ✅ Imports only what you need
import { Auth } from '@imtbl/auth';
import { Orderbook } from '@imtbl/orderbook';
```
Using modular packages can significantly reduce your bundle size by importing only what you need.
Clear your cache and reinstall dependencies:
```bash theme={null}
# Remove node_modules and package lock
rm -rf node_modules package-lock.json
# Clear npm cache
npm cache clean --force
# Reinstall dependencies
npm install
```
**For Vite projects:**
```bash theme={null}
rm -rf node_modules .vite package-lock.json
npm cache clean --force
npm install
```
**For Next.js projects:**
```bash theme={null}
rm -rf node_modules .next package-lock.json
npm cache clean --force
npm install
```
For framework-specific bundler configuration, see the [Framework Setup](/docs/sdks/typescript/overview#framework-setup) tabs in the overview.
## Regional Support
The Immutable Game SDK supports only the regions that Immutable Passport supports.
Passport is a globally available product. However, our wallet infrastructure is subject to the regulation of the US Department of the Treasury's Office of Foreign Assets Control ("OFAC"). OFAC administers and enforces comprehensive and targeted economic and trade sanctions programs on multiple countries and regions.
Users attempting to access Passport in any of the regions under OFAC sanction will have their access blocked and will be unable to use our product. Additionally, components of Passport's infrastructure also rely on technology provided by [Magic](https://magic.link/), which maintains further details regarding unsupported regions on their website [here](https://help.magic.link/knowledge/prohibited-regions).
## Authentication
The `redirectUri` in your code must **exactly** match what's configured in [Hub](https://hub.immutable.com).
Common mismatches:
* `http` vs `https`
* Trailing slash (`/callback` vs `/callback/`)
* Port number differences
* Different paths
```typescript theme={null}
// ❌ Wrong - trailing slash mismatch
redirectUri: 'http://localhost:3000/callback/'
// ✅ Correct - matches Hub config exactly
redirectUri: 'http://localhost:3000/callback'
```
The redirect URI must match **exactly** including trailing slashes and port numbers.
Verify the following:
1. `clientId` matches your Hub configuration
2. You're using the correct environment (Sandbox vs Production)
3. `publishableKey` is set correctly
Browsers block popups that aren't triggered by user interaction.
```typescript theme={null}
// ❌ Wrong - called on page load
useEffect(() => {
auth.login(); // Blocked!
}, []);
// ✅ Correct - triggered by user click
auth.login()}>
Login
```
Always trigger authentication flows from user interactions (button clicks) to avoid popup blockers.
Ensure `loginCallback()` is called on your redirect URI page:
```typescript theme={null}
// app/callback/page.tsx
useEffect(() => {
auth.loginCallback()
.then(() => router.push('/'))
.catch(console.error);
}, []);
```
## Transactions
User cancelled the transaction in the Passport popup. Handle this gracefully:
```typescript theme={null}
try {
await walletClient.sendTransaction({ to, value });
} catch (error) {
if (error.name === 'UserRejectedRequestError') {
// User cancelled - show friendly message
return;
}
throw error;
}
```
The user doesn't have enough IMX for the transaction + gas fees.
```typescript theme={null}
import { formatEther } from 'viem';
const balance = await publicClient.getBalance({ address });
console.log('Balance:', formatEther(balance), 'IMX');
```
Direct users to fund their wallet via the [Checkout widgets](/docs/products/checkout/fund).
Usually caused by pending transactions. Wait for pending transactions to confirm:
```typescript theme={null}
const pendingNonce = await publicClient.getTransactionCount({
address,
blockTag: 'pending',
});
```
## Performance & Debugging
Initialize once and reuse the instance:
```typescript theme={null}
// ❌ Wrong - creates new instance every call
function getAuth() {
return new Auth({ ... });
}
// ✅ Correct - singleton pattern
let auth: Auth | null = null;
function getAuth() {
if (!auth) {
auth = new Auth({ ... });
}
return auth;
}
```
Use the singleton pattern to initialize SDK instances once and reuse them throughout your application.
Enable debug logs in the browser console:
```typescript theme={null}
// In browser console
localStorage.setItem('debug', 'imtbl:*');
```
Then refresh the page to see SDK debug output.
SDK calls go to:
* **Sandbox**: `api.sandbox.immutable.com`
* **Production**: `api.immutable.com`
Check the browser Network tab for failed requests and error responses.
| Code | Meaning | Solution |
| ---- | ------------ | ------------------------------------------------- |
| 401 | Unauthorized | Check API key or access token |
| 403 | Forbidden | Check permissions in Hub |
| 429 | Rate limited | Implement exponential backoff |
| 500 | Server error | Retry with backoff, contact support if persistent |
## Integration & Usage
viem has built-in Immutable Chain support:
```typescript theme={null}
import { createWalletClient, createPublicClient, custom, http } from 'viem';
import { immutableZkEvm, immutableZkEvmTestnet } from 'viem/chains';
import { connectWallet } from '@imtbl/wallet';
const provider = await connectWallet({ auth });
const walletClient = createWalletClient({
chain: immutableZkEvmTestnet,
transport: custom(provider),
});
const publicClient = createPublicClient({
chain: immutableZkEvmTestnet,
transport: http(),
});
const [address] = await walletClient.getAddresses();
const hash = await walletClient.sendTransaction({
to: '0x...',
value: parseEther('0.1'),
});
```
Use `@imtbl/webhook` to verify incoming webhooks from Immutable:
```typescript theme={null}
import { validateWebhook } from '@imtbl/webhook';
app.post('/webhook', (req, res) => {
const signature = req.headers['x-immutable-signature'] as string;
const rawBody = req.body; // Must be raw body, not parsed JSON
const isValid = validateWebhook(
rawBody,
signature,
process.env.WEBHOOK_SECRET!
);
if (!isValid) {
return res.status(403).send('Invalid signature');
}
res.status(200).send('OK');
});
```
For browser-only applications without a build step:
```html theme={null}
```
CDN deployment is useful for quick prototypes and static sites. For production applications, use npm packages with proper bundling.
Requires TypeScript 5.0+ with `moduleResolution: "bundler"` in `tsconfig.json`.
```typescript theme={null}
// ✅ Correct - imports only the module you need
import { Passport } from '@imtbl/sdk/passport';
import { Orderbook } from '@imtbl/sdk/orderbook';
import { BlockchainData } from '@imtbl/sdk/blockchain-data';
// ❌ Avoid - imports entire SDK
import { Passport, Orderbook } from '@imtbl/sdk';
```
**Breaking Change**: V2 SDK uses **Ethers.js v6** (upgraded from v5). If your application uses Ethers.js, you must migrate. Key changes:
* `WrappedBrowserProvider` for Checkout SDK
* `passport.connectEvm()` is now **async** — must `await` before passing to Checkout
* See [Ethers.js v6 migration guide](https://docs.ethers.org/v6/migrating/) for details
***
## Still Need Help?
Complete TypeScript SDK documentation
Report TypeScript SDK issues
Contact player support
Browse all documentation
# TypeScript SDK
Source: https://docs.immutable.com/docs/sdks/typescript/overview
Typed clients for all Immutable APIs and services.
**Who is this for?** Web developers and TypeScript developers looking to build web3 applications and integrate blockchain features seamlessly.
## Packages
Install only what you need:
| Package | Purpose | Install |
| ------------------------- | -------------------------------------- | ------------------------------- |
| `@imtbl/auth` | Authentication & sessions | `npm i @imtbl/auth` |
| `@imtbl/wallet` | Embedded wallets & transactions | `npm i @imtbl/wallet` |
| `@imtbl/auth-next-server` | Next.js server-side auth (Auth.js v5) | `npm i @imtbl/auth-next-server` |
| `@imtbl/auth-next-client` | Next.js client-side hooks & components | `npm i @imtbl/auth-next-client` |
| `@imtbl/orderbook` | NFT trading | `npm i @imtbl/orderbook` |
| `@imtbl/blockchain-data` | On-chain data queries (Indexer) | `npm i @imtbl/blockchain-data` |
| `@imtbl/minting-backend` | Server-side minting | `npm i @imtbl/minting-backend` |
| `@imtbl/contracts` | Smart contract ABIs & types | `npm i @imtbl/contracts` |
| `@imtbl/webhook` | Webhook signature validation | `npm i @imtbl/webhook` |
| `@imtbl/config` | Environment configuration | `npm i @imtbl/config` |
Install individual packages instead of `@imtbl/sdk` for smaller bundles and better tree-shaking.
## Installation
```bash theme={null}
npm install @imtbl/sdk
```
```bash theme={null}
yarn add @imtbl/sdk
```
```bash theme={null}
pnpm add @imtbl/sdk
```
Or install individual packages for smaller bundles:
```bash theme={null}
npm install @imtbl/auth @imtbl/wallet @imtbl/config
```
## Framework Setup
Use `@imtbl/auth-next-server` and `@imtbl/auth-next-client` for full Next.js integration with server-side session management, automatic token refresh, and route protection.
These packages require Next.js 14+ with the App Router. For Pages Router or other React frameworks, use `@imtbl/auth` directly.
### Prerequisites
* **Client ID** from your [Passport client in Hub](/docs/products/passport/clients)
* Next.js 14 or 15 with App Router
* An `AUTH_SECRET` environment variable (any random string, minimum 32 characters)
### Installation
```bash theme={null}
npm install @imtbl/auth-next-server @imtbl/auth-next-client next-auth@5
```
### Setup
Create your auth configuration:
```typescript theme={null}
// lib/auth.ts
import { NextAuth, createAuthConfig } from "@imtbl/auth-next-server";
export const { handlers, auth, signIn, signOut } = NextAuth({
...createAuthConfig({
clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
}),
secret: process.env.AUTH_SECRET,
trustHost: true,
});
```
Create the API route handler:
```typescript theme={null}
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
```
Wrap your app with `SessionProvider`:
```tsx theme={null}
// app/layout.tsx
import { SessionProvider } from "next-auth/react";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
```
Set environment variables:
```env theme={null}
# .env.local
NEXT_PUBLIC_IMMUTABLE_CLIENT_ID=your_client_id
NEXT_PUBLIC_BASE_URL=http://localhost:3000
AUTH_SECRET=your-secret-key-min-32-characters
```
Handle the OAuth redirect after login.
```tsx theme={null}
// app/callback/page.tsx
'use client';
import { CallbackPage, type ImmutableAuthConfig } from '@imtbl/auth-next-client';
const config: ImmutableAuthConfig = {
clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
audience: 'platform_api',
scope: 'openid profile email offline_access transact',
};
export default function Callback() {
return (
Completing authentication...}
/>
);
}
```
```env theme={null}
# .env.local
NEXT_PUBLIC_IMMUTABLE_CLIENT_ID=your_client_id
NEXT_PUBLIC_BASE_URL=http://localhost:3000
AUTH_SECRET=your-secret-key-min-32-characters
```
For login, logout, session management, and wallet operations using the client hooks, see the **Next.js** tabs on the [Authentication](/docs/products/passport/authentication) and [Wallet](/docs/products/passport/wallet) pages.
```bash theme={null}
npm install @imtbl/auth @imtbl/wallet
```
### Initialization
```typescript theme={null}
// src/lib/immutable.ts
import { Auth } from '@imtbl/auth';
import { connectWallet, ZkEvmProvider } from '@imtbl/wallet';
export const auth = new Auth({
clientId: import.meta.env.VITE_CLIENT_ID,
redirectUri: `${window.location.origin}/callback`,
logoutRedirectUri: window.location.origin,
audience: 'platform_api',
scope: 'openid offline_access email transact',
});
let providerPromise: Promise | null = null;
export function getProvider() {
if (!providerPromise) {
providerPromise = connectWallet({ auth });
}
return providerPromise;
}
```
### Environment Variables
```env theme={null}
VITE_CLIENT_ID=your_client_id
```
### Vite Configuration (Required)
Vite requires Node.js polyfills for the SDK. Without this configuration, builds will fail.
```bash theme={null}
npm install --save-dev vite-plugin-node-polyfills
```
```typescript theme={null}
// vite.config.ts
import { defineConfig } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
export default defineConfig({
plugins: [
nodePolyfills()
]
});
```
Use Passport with wagmi for React hooks:
```bash theme={null}
npm install wagmi @imtbl/auth @imtbl/wallet
```
### Configuration
```typescript theme={null}
import { createConfig, http } from 'wagmi';
import { immutableZkEvmTestnet } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';
export const wagmiConfig = createConfig({
chains: [immutableZkEvmTestnet],
connectors: [injected()],
transports: {
[immutableZkEvmTestnet.id]: http(),
},
});
```
### Component Example
```tsx theme={null}
import { useAccount, useConnect } from 'wagmi';
function WalletButton() {
const { address, isConnected } = useAccount();
const { connect, connectors } = useConnect();
if (isConnected) {
return {address?.slice(0, 8)}... ;
}
return (
connect({ connector: connectors[0] })}>
Connect
);
}
```
## Next.js Configuration
### `createAuthConfig`
Creates an Auth.js v5 configuration object for Immutable authentication.
| Option | Type | Required | Default |
| ---------------------- | -------- | -------------------------- | ------------------------------------------------ |
| `clientId` | `string` | Yes (when config provided) | Sandbox client ID |
| `redirectUri` | `string` | Yes (when config provided) | `origin + '/callback'` |
| `audience` | `string` | No | `"platform_api"` |
| `scope` | `string` | No | `"openid profile email offline_access transact"` |
| `authenticationDomain` | `string` | No | `"https://auth.immutable.com"` |
When called with no arguments, `createAuthConfig()` uses sandbox defaults for quick prototyping.
Zero-config mode uses a shared public sandbox client ID. For production, always use your own client ID from [Immutable Hub](https://hub.immutable.com).
### Extending the configuration
You can spread the base config and add any Auth.js options. This is the pattern used in the Passport sample app to support multiple environments:
```typescript theme={null}
import { NextAuth, createAuthConfig } from "@imtbl/auth-next-server";
export const { handlers, auth, signIn, signOut } = NextAuth({
...createAuthConfig({
clientId: process.env.NEXT_PUBLIC_IMMUTABLE_CLIENT_ID!,
redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/callback`,
}),
secret: process.env.AUTH_SECRET,
trustHost: true,
basePath: "/api/auth",
});
```
## Next.js Server Utilities
Use these in Server Components and Server Actions to handle authentication on the server.
### `getAuthProps`
Pass authentication state to a Client Component when data fetching happens client-side.
```typescript theme={null}
// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { getAuthProps } from "@imtbl/auth-next-server";
import { redirect } from "next/navigation";
import { DashboardClient } from "./DashboardClient";
export default async function DashboardPage() {
const authProps = await getAuthProps(auth);
if (authProps.authError) {
redirect("/login");
}
return ;
}
```
### `getAuthenticatedData`
Fetch data server-side for faster initial loads, with automatic client-side fallback when the token is expired.
```typescript theme={null}
// app/profile/page.tsx
import { auth } from "@/lib/auth";
import { getAuthenticatedData } from "@imtbl/auth-next-server";
import { redirect } from "next/navigation";
async function fetchProfile(accessToken: string) {
const res = await fetch("https://api.immutable.com/v1/user/profile", {
headers: { Authorization: `Bearer ${accessToken}` },
});
return res.json();
}
export default async function ProfilePage() {
const result = await getAuthenticatedData(auth, fetchProfile);
if (result.authError) redirect("/login");
return ;
}
```
### `createProtectedFetchers`
Define auth error handling once and reuse it across all protected pages.
```typescript theme={null}
// lib/protected.ts
import { auth } from "@/lib/auth";
import { createProtectedFetchers } from "@imtbl/auth-next-server";
import { redirect } from "next/navigation";
export const { getAuthProps, getData } = createProtectedFetchers(
auth,
(error) => redirect(`/login?error=${error}`),
);
```
```typescript theme={null}
// app/settings/page.tsx
import { getAuthProps } from "@/lib/protected";
export default async function SettingsPage() {
const authProps = await getAuthProps();
return ;
}
```
### `getValidSession`
Get fine-grained control over different authentication states.
```typescript theme={null}
import { auth } from "@/lib/auth";
import { getValidSession } from "@imtbl/auth-next-server";
export default async function AccountPage() {
const result = await getValidSession(auth);
switch (result.status) {
case "authenticated":
return ;
case "token_expired":
return ;
case "unauthenticated":
return ;
case "error":
return ;
}
}
```
## Next.js Route Protection
### Middleware
Protect entire sections of your app at the routing level before pages render. Use middleware when you have groups of pages that all require authentication (such as `/dashboard/*` or `/settings/*`) and you want to redirect unauthenticated users before any page code runs.
Do not use middleware for pages that show different content for authenticated vs unauthenticated users, or for public pages with optional authenticated features. Use page-level checks with `getAuthProps` or `getValidSession` instead.
```typescript theme={null}
// middleware.ts
import { createAuthMiddleware } from "@imtbl/auth-next-server";
import { auth } from "@/lib/auth";
export default createAuthMiddleware(auth, {
loginUrl: "/login",
publicPaths: ["/", "/about", "/api/public"],
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```
| Option | Type | Description |
| ---------------- | ---------------------- | --------------------------------------------------------------- |
| `loginUrl` | `string` | Redirect target for unauthenticated users (default: `"/login"`) |
| `protectedPaths` | `(string \| RegExp)[]` | Paths that require authentication |
| `publicPaths` | `(string \| RegExp)[]` | Paths that skip authentication (takes precedence) |
### Protected API Routes
Use `withAuth` to protect individual Route Handlers or Server Actions.
```typescript theme={null}
// app/api/user/inventory/route.ts
import { auth } from "@/lib/auth";
import { withAuth } from "@imtbl/auth-next-server";
import { NextResponse } from "next/server";
export const GET = withAuth(auth, async (session, request) => {
const inventory = await fetchUserInventory(session.accessToken);
return NextResponse.json(inventory);
});
```
```typescript theme={null}
// app/actions/transfer.ts
"use server";
import { auth } from "@/lib/auth";
import { withAuth } from "@imtbl/auth-next-server";
export const transferAsset = withAuth(auth, async (session, formData: FormData) => {
const assetId = formData.get("assetId") as string;
const toAddress = formData.get("toAddress") as string;
return await executeTransfer({
from: session.user.sub,
to: toAddress,
assetId,
accessToken: session.accessToken,
});
});
```
Inside `withAuth` handlers, the `session` object includes `accessToken` directly since these run server-side. This is different from the client-side `useImmutableSession` hook where you must use `getAccessToken()`.
## Next.js Session Type Reference
The server-side session (available in `withAuth` handlers and `getAuthenticatedData` fetchers) includes the following fields:
| Field | Type | Description |
| ------------------------ | --------- | ----------------------------------------- |
| `user.sub` | `string` | Immutable user ID |
| `user.email` | `string?` | User's email address |
| `user.nickname` | `string?` | User's display name |
| `accessToken` | `string` | Current access token (server-side only) |
| `refreshToken` | `string?` | Refresh token |
| `accessTokenExpires` | `number` | Token expiry timestamp (ms) |
| `zkEvm.ethAddress` | `string?` | zkEVM wallet address |
| `zkEvm.userAdminAddress` | `string?` | Admin wallet address |
| `error` | `string?` | `"TokenExpired"` or `"RefreshTokenError"` |
The client-side session from `useImmutableSession` intentionally omits `accessToken` to prevent use of stale tokens. Use `getAccessToken()` for API calls or `getUser()` for wallet integration.
The `idToken` is not stored in the session cookie to stay within CDN header size limits. On the client, `@imtbl/auth-next-client` persists it in `localStorage` so wallet operations can access it via `getUser()`.
## Next.js Exported Utilities
The server package exports low-level utilities for manual token handling:
```typescript theme={null}
import {
isTokenExpired,
refreshAccessToken,
extractZkEvmFromIdToken,
} from "@imtbl/auth-next-server";
```
| Utility | Description |
| ---------------------------------- | -------------------------------------------------------------------- |
| `isTokenExpired(expiresAt)` | Check if an access token has expired |
| `refreshAccessToken(token)` | Manually refresh tokens using a refresh token |
| `extractZkEvmFromIdToken(idToken)` | Extract zkEVM claims (ethAddress, userAdminAddress) from an ID token |
## Environment Configuration
| Environment | Chain | API Base |
| ------------ | ----------------- | --------------------------- |
| `SANDBOX` | Immutable Testnet | `api.sandbox.immutable.com` |
| `PRODUCTION` | Immutable Mainnet | `api.immutable.com` |
## Next Steps
### Getting Started
Set up credentials and OAuth client in Immutable Hub
Initialize Passport and implement login
Access wallet addresses and balances
Server-side minting for NFTs at scale
### Additional Resources
Understand the security model and smart contract wallets
Common questions and troubleshooting
SDK source code
Package registry
# Advanced Configuration
Source: https://docs.immutable.com/docs/sdks/unity/advanced-configuration
Custom WebView, IL2CPP support, and advanced SDK configuration
## Custom Windows WebView
The default Unity Web Browser (UWB) doesn't support IL2CPP. For IL2CPP builds or more control, integrate a third-party WebView.
### Vuplex Integration
[Vuplex](https://vuplex.com/) is a commercial WebView that supports IL2CPP.
#### 1. Install Vuplex
1. Purchase [Vuplex Windows WebView](https://developer.vuplex.com/webview/overview)
2. Import the `.unitypackage` from your Vuplex dashboard
3. Include `Core/` and `Standalone/` directories (skip `Mac/`)
#### 2. Implement the Interface
Create a class implementing `IWindowsWebBrowserClient`:
```csharp theme={null}
#if UNITY_STANDALONE_WIN
using Cysharp.Threading.Tasks;
using Immutable.Browser.Core;
using Vuplex.WebView;
public class VuplexWebView : IWindowsWebBrowserClient
{
public event OnUnityPostMessageDelegate OnUnityPostMessage;
private IWebView webView;
public VuplexWebView()
{
webView = Web.CreateWebView();
}
public async UniTask Init()
{
await webView.Init(1, 1);
webView.MessageEmitted += (sender, eventArgs) =>
{
OnUnityPostMessage?.Invoke(eventArgs.Value);
};
}
public void LoadUrl(string url)
{
webView.LoadUrl(url);
}
public async void ExecuteJavaScript(string js)
{
await webView.ExecuteJavaScript(js);
}
public string GetPostMessageApiCall()
{
return "window.vuplex.postMessage";
}
public void Dispose()
{
webView.Dispose();
}
}
#endif
```
#### 3. Initialize with Custom WebView
```csharp theme={null}
passport = await Passport.Init(clientId, environment
#if UNITY_STANDALONE_WIN || (UNITY_EDITOR_WIN && UNITY_STANDALONE_WIN)
, windowsWebBrowserClient: new VuplexWebView()
#endif
);
```
#### 4. Configure Scripting Defines
1. Go to **File** → **Build Settings** → **Player Settings**
2. Navigate to **Player** → **Other Settings**
3. Add `IMMUTABLE_CUSTOM_BROWSER` to **Scripting Define Symbols**
4. Click **Apply**
This excludes the default WebView from your build.
#### 5. Exclude Vuplex from Non-Windows Builds
Follow [Vuplex's platform exclusion guide](https://support.vuplex.com/articles/exclude-from-build) to prevent Vuplex from being included in non-Windows builds.
***
## Custom Timeout Configuration
Override default timeouts for SDK operations:
```csharp theme={null}
// Set custom timeout (default is 60 seconds)
passport.SetCallTimeout(TimeSpan.FromMinutes(2));
```
***
## Session Persistence
The SDK automatically persists sessions. To manually control:
```csharp theme={null}
// Check for existing session
bool hasSession = await passport.HasCredentialsSaved();
if (hasSession)
{
// Silent login (no UI)
await passport.Login(useCachedSession: true);
}
else
{
// Full login flow
await passport.Login();
}
```
***
## Logging
The Passport SDK provides logging so you can monitor and troubleshoot its behaviour. You can set the log level and optionally redact sensitive data from logs.
### Log level
Use the `LogLevel` property to control the severity of logged messages. The default is `LogLevel.Info`.
| Level | Description |
| ---------------- | ------------------------------------------- |
| `LogLevel.Debug` | Detailed information for debugging |
| `LogLevel.Info` | General SDK operation information (default) |
| `LogLevel.Warn` | Warnings about potential issues |
| `LogLevel.Error` | Errors indicating SDK failures |
```csharp theme={null}
// Set log level to Debug for verbose output
Passport.LogLevel = LogLevel.Debug;
```
### Redacting sensitive tokens in logs
By default, access and ID tokens are logged in full. Set `RedactTokensInLogs` to `true` to replace them with `[REDACTED]` in log output.
```csharp theme={null}
Passport.RedactTokensInLogs = true;
```
Use this when debugging or sharing logs to avoid exposing sensitive data.
***
## Next Steps
iOS, Android configuration
Common questions
# FAQ
Source: https://docs.immutable.com/docs/sdks/unity/faq
# Frequently Asked Questions
Common questions and solutions for platform setup, authentication, errors, and integration with the Unity SDK.
## Platform & Technical Setup
Yes, you can test the SDK using the Unity Editor for Android and iOS on both Mac and Windows. However, the native Android and iOS WebViews cannot run in the editor. The macOS WebView is used for the Mac Unity Editor, and the Windows WebView is used for the Windows Unity Editor.
| Testing Stage | Environment | Purpose |
| ------------- | -------------------- | -------------------------------- |
| Development | Unity Editor | Quick iteration on logic |
| Integration | Android/iOS Emulator | Test mobile WebView behavior |
| Final QA | Physical Device | Test performance & real hardware |
Set up emulators early in development. Editor testing alone will miss mobile-specific issues.
You're attempting to run an Android/iOS game in the Unity Editor. Native Android/iOS WebViews cannot run in the Unity Editor—the Editor uses desktop WebView (macOS or Windows) instead.
You must run your game through an Android/iOS emulator or device, or switch your build target to "PC, Mac & Linux Standalone" in **File → Build Settings** for Editor testing.
Always test on real devices before production. Emulators don't catch all issues.
Our SDK doesn't provide out-of-the-box support for IL2CPP on Windows. However, you can integrate a third-party Unity Windows WebView that supports IL2CPP, such as [Vuplex](https://developer.vuplex.com/webview/overview).
See our [custom Windows WebView documentation](/build/unity/advanced/custom-windows-webview) for detailed instructions.
IL2CPP is supported on Android and iOS. Windows requires additional WebView integration.
## Regional Support
The Immutable Game SDK supports only the regions that Immutable Passport supports.
Passport is a globally available product. However, our wallet infrastructure is subject to the regulation of the US Department of the Treasury's Office of Foreign Assets Control ("OFAC"). OFAC administers and enforces comprehensive and targeted economic and trade sanctions programs on multiple countries and regions.
Users attempting to access Passport in any of the regions under OFAC sanction will have their access blocked and will be unable to use our product. Additionally, components of Passport's infrastructure also rely on technology provided by [Magic](https://magic.link/), which maintains further details regarding unsupported regions on their website [here](https://help.magic.link/knowledge/prohibited-regions).
## Authentication
This is not possible due to security reasons. Vendors like Google also block login through webviews because the hosting applications can steal credentials.
Never use WebView for authentication. It's a security risk and violates OAuth best practices.
See the "Why is the in-app browser used for Passport login on mobile and not a webview?" section below for more details.
The in-app browser is specifically designed for single sign-on (SSO) purposes, making it a much more secure option. This browser runs on a separate process from the hosting game, meaning the game cannot access it, modify any content, or inject malicious code.
| Feature | In-App Browser | WebView |
| -------------------------- | ------------------ | --------------------- |
| Process isolation | ✅ Separate process | ❌ Same process as app |
| App can inject code | ❌ No | ✅ Yes (security risk) |
| App can intercept requests | ❌ No | ✅ Yes (security risk) |
| Designed for SSO | ✅ Yes | ❌ No |
WebView gives the hosting game full control, allowing it to intercept requests and inject JavaScript. This is why OAuth providers like Google block WebView authentication.
**Platform implementations:**
* Android: [Chrome Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs)
* iOS/macOS: [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession)
To securely implement PKCE, we must use `ASWebAuthenticationSession`, which shows an alert. This alert cannot be removed or modified as the operating system triggers it.
This is a security feature designed to protect users from unauthorized authentication attempts. Any attempt to bypass it will cause app rejection.
[Learn more about ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession)
This error occurs because you are attempting to log in using a WebView instead of a browser. Google blocks authentication attempts from WebViews because they can be exploited to steal user credentials. The hosting app has full control over WebView content, including the ability to inject JavaScript and intercept requests.
Use the system browser or in-app browser (SFSafariViewController/Chrome Custom Tabs) instead of WebView. The SDK uses the system browser by default.
Refer to the "Can I use a WebView instead of the system browser for login?" section above for more information.
Custom URL schemes are used because [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) doesn't reliably trigger Universal Links after authentication. Universal Links depend on Safari's verification process, which can be inconsistent. Custom URL schemes ensure immediate redirection to the app and a reliable experience.
This approach is secure because [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) ensures that only the calling app receives the callback even if other apps register the same custom URL scheme. This prevents callback interception by other apps and ensures the security of the authentication process.
## Errors & Troubleshooting
If you encounter a `TimeoutException`, it indicates that the function you called took more than one minute to return a response. Although this is the default timeout value, the timeout can be customized using the `SetCallTimeout` function.
```csharp theme={null}
// Increase timeout globally
passport.SetCallTimeout(120); // 2 minutes
// For blockchain transactions
passport.SetCallTimeout(180); // 3 minutes
```
To identify the specific function that caused this exception, check the bottom of the stack trace or the logs generated before the exception was thrown.
For blockchain transactions, consider increasing timeout to 180 seconds (3 minutes) to account for network congestion.
Our SDK includes a [post-process script](https://github.com/immutable/unity-immutable-sdk/blob/main/src/Packages/Passport/Editor/PassportPostprocess.cs) that copies an [`index.html`](https://github.com/immutable/unity-immutable-sdk/blob/main/src/Packages/Passport/Runtime/Resources/index.html) file required to run the SDK.
This error could be because the files weren't copied correctly to the build. Check if you got the message "Successfully copied Passport files" in the console output during build.
Another reason for this error could be that the path to the `index.html` file is unexpected. We try to cover all the possible cases in our SDK, but if you suspect this is the problem, please [create a GitHub issue](https://github.com/immutable/unity-immutable-sdk/issues).
These errors are usually caused by missing large files stored in [Git Large File Storage (Git LFS)](https://git-lfs.github.com/). Make sure Git LFS is installed **before** cloning the repository.
Versions later than [v1.36.9](https://github.com/immutable/unity-immutable-sdk/releases/tag/v1.36.9) no longer bundle a fork of Unity Web Browser (UWB). Instead, Windows embedded WebView support is provided either by Vuplex WebView or by an optional integration with the official Unity Web Browser packages.
You will usually see this error when you're using an older SDK version that still expects bundled UWB binaries and those `.dll` files are missing (for example because Git LFS wasn't installed), or when you've installed Unity Web Browser as an optional dependency but one or more of its engine packages / assemblies are missing or misconfigured.
We strongly recommend upgrading to the latest version of the Unity SDK to avoid Git LFS issues entirely.
**If using latest SDK:**
* If you want to use UWB for Windows desktop, follow the [WebView Setup](/build/unity/usage/passport/setup#webview-setup) docs
* Otherwise, rely on Vuplex WebView for embedded flows
**If using older SDK (\< v1.36.9):**
* Install Git LFS before cloning the repository so that the UWB `.dll` files are downloaded correctly
* If the issue persists, reinstall Git LFS and try a fresh clone
This error is commonly caused by using an incompatible Application Entry Point. The Immutable SDK only supports `Activity`, not `GameActivity`.
Try the following:
1. Set your [Application Entry Point](https://docs.unity3d.com/6000.0/Documentation/Manual/android-application-entries-set.html) to `Activity` in **Project Settings** → **Player** → **Android** → **Other Settings** → **Configuration** → **Application Entry Point**
2. Open your `AndroidManifest.xml` file and remove the `UnityPlayerGameActivity` activity block, keeping only the `UnityPlayerActivity` block
Unity 6 changed the default Application Entry Point to GameActivity. You must manually change it back to Activity for Immutable SDK compatibility.
If this doesn't resolve the issue, please [raise an issue](https://github.com/immutable/unity-immutable-sdk/issues) in the Immutable Unity SDK repository.
Android minification can obfuscate SDK classes, breaking authentication. Please refer to [this](/x/sdks/unity#proguard) section of our documentation for the required ProGuard configuration.
Always test with minification enabled before production to catch ProGuard issues early.
Please try the following items to see if the issue goes away:
1. Before starting your game, check the Task Manager for Chrome Embedded Framework (CEF) and kill it if it's running. Our code handles this, but you may need to do it manually.
2. Restart your computer.
3. Increase the engine start-up timeout in the `Passport.Init` function [here](https://github.com/immutable/unity-immutable-sdk/blob/98eabc0a140619cf0f1526792cd746e48d42d934/src/Packages/Passport/Runtime/Scripts/Public/Passport.cs#L56).
Our code attempts to kill CEF automatically, but you may need to do it manually in some cases.
This error indicates that Unity Web Browser's Chrome Embedded Framework (CEF) engine is not installed correctly or cannot be found at runtime.
To fix this, ensure that you have installed the correct Unity Web Browser packages for Windows:
* `dev.voltstro.unitywebbrowser`
* `dev.voltstro.unitywebbrowser.engine.cef`
* `dev.voltstro.unitywebbrowser.engine.cef.win.x64` (for 64-bit Windows builds)
After installing these packages, re-open your project so Unity can import the assets and regenerate any required configuration.
UWB is an optional, Windows-only dependency. If it is not installed, Passport will fall back to using the system browser or any custom `IWindowsWebBrowserClient` implementation you provide.
## Integration & Usage
The [Unity SDK sample](https://github.com/immutable/unity-immutable-sdk/tree/main/sample) is a working project that demonstrates how to use the main SDK functions. Use it as a reference when integrating the SDK into your own game.
**Additional resources:**
* [Unity SDK Reference](/reference/unity) - Complete API documentation
You can use the zkEVM Send Transaction function in the SDK to call your smart contract crafting function.
Crafting typically involves calling a smart contract that burns input NFTs and mints a new crafted NFT.
Yes! The Unity and Unreal SDKs share a common architecture that you can adapt for custom game engines.
See the [Game Bridge Architecture](/docs/sdks/game-bridge-architecture) documentation for more details.
***
## Still Need Help?
Complete Unity SDK documentation
Report Unity SDK issues
Contact player support
Browse all documentation
# Unity SDK
Source: https://docs.immutable.com/docs/sdks/unity/overview
Native C# bindings for Passport authentication, wallet operations, and blockchain data queries.
**Who is this for?** Game developers using Unity who want to integrate web3 features, NFTs, and blockchain functionality into their games.
## Platform Compatibility
| Unity Version | Windows | macOS | Android | iOS |
| --------------- | -------------- | -------------- | -------------- | -------------- |
| **Unity 6** | ✅ Full support | ✅ Full support | ✅ Full support | ✅ Full support |
| **2021.3 LTS+** | ✅ Full support | ✅ Full support | ✅ Full support | ✅ Full support |
**Unity 6 Android Users**: If using Unity 6 with Android, you must set the Application Entry Point to `Activity` (not the default `GameActivity`). See [Unity 6 Configuration](#unity-6-configuration) below.
## Prerequisites
* **Unity**: 2021.3 LTS or later
* **.NET Standard**: 2.1
* **UniTask**: The [UniTask](https://github.com/Cysharp/UniTask) package (v2.3.3) is a required dependency of our SDK. Follow the instructions [here](https://github.com/Cysharp/UniTask#upm-package) to install it.
* **Git LFS**: The SDK repository uses [Git Large File Storage](https://git-lfs.github.com/) to manage `.dll` files and other binary assets. Install Git LFS before cloning or Unity Package Manager may fail to download binary dependencies correctly.
## Installation
1. Install [UniTask](https://github.com/Cysharp/UniTask#upm-package) v2.3.3 or later
The UniTask package is a dependency of our SDK.
2. Install [Git LFS](https://git-lfs.github.com/)
Git LFS must be installed **before** adding the SDK. The SDK contains `.dll` files stored in Git LFS that won't download correctly otherwise.
3. Add the Immutable SDK:
1) Open **Window** → **Package Manager**
2) Click **+** and select **Add package from git URL...**
3) Enter `https://github.com/immutable/unity-immutable-sdk.git?path=/src/Packages/Passport` and click **Add**
1. Open your project's `Packages/manifest.json` file
2. Add the following in the `dependencies` block:
```json theme={null}
"com.immutable.passport": "https://github.com/immutable/unity-immutable-sdk.git?path=/src/Packages/Passport"
```
#### Install a Specific Version
To install a specific version of the SDK using either method above, append `#` followed by the version tag to the git URL:
```
https://github.com/immutable/unity-immutable-sdk.git?path=/src/Packages/Passport#v1.0.0
```
For mobile platform setup (Android, iOS), see [Platform-Specific Configuration](/docs/sdks/unity/overview#platform-specific-configuration).
### Unity 6 Configuration
**Unity 6 Android Required**: If using Unity 6 with Android, you must configure the Application Entry Point.
1. Go to **Project Settings** → **Player** → **Android** → **Other Settings** → **Configuration**
2. Set **Application Entry Point** to `Activity` (not `GameActivity`)
3. Open `AndroidManifest.xml` and remove the `UnityPlayerGameActivity` activity block; keep only the `UnityPlayerActivity` block
## Platform-Specific Configuration
### Platform Requirements
* **Windows**: 10 or later
### Scripting Backend Support
| Backend | Support | Notes |
| ---------- | -------------------------- | -------------------------------------------------------------------------------------- |
| **Mono** | ✅ Fully supported | Uses default Unity Web Browser (UWB) |
| **IL2CPP** | ⚠️ Requires custom WebView | See [Advanced Configuration](/docs/sdks/unity/advanced-configuration) for Vuplex setup |
### Default WebView Setup
The SDK uses [Unity Web Browser (UWB)](https://github.com/Voltstro-Studios/UnityWebBrowser) by default on Windows. UWB is not bundled with the SDK, so you must install it.
1. Add the VoltUPR registry and required scopes, then install UWB via the Package Manager. See the [UWB GitHub repo](https://github.com/Voltstro-Studios/UnityWebBrowser) and [VoltUPR setup guide](https://upr.voltstro.dev/-/web/about).
2. Import these packages:
* `dev.voltstro.unitywebbrowser`
* `dev.voltstro.unitywebbrowser.engine.cef`
* `dev.voltstro.unitywebbrowser.engine.cef.win.x64`
3. Confirm **UWB\_WEBVIEW** is present in your project after the packages are installed.
For IL2CPP builds, use a custom WebView like Vuplex. See [Advanced Configuration](/docs/sdks/unity/advanced-configuration).
### Platform Requirements
* **macOS**: 12.5 or later
No additional configuration required for macOS.
### Platform Requirements
* **Minimum Android Version**: 10 or later (API Level 24+)
* **Target API**: 33 or higher recommended
### Enable custom manifest and Gradle
1. Go to **Edit** → **Project Settings** → **Player** → **Android**
2. Expand **Publishing Settings**
3. In **Build**, enable **Custom Main Manifest** and **Custom Main Gradle Template**
Unity generates `Assets/Plugins/Android/AndroidManifest.xml` and `Assets/Plugins/Android/mainTemplate.gradle`. Edit these files (or your existing custom ones) as below.
### AndroidManifest.xml
Add this activity inside the `` element. Set `android:scheme` and `android:host` to match the redirect URIs you use in Passport (e.g. `mygame` and `callback` for `mygame://callback`):
```xml theme={null}
```
### mainTemplate.gradle
Add the Chrome Custom Tabs dependency inside the `dependencies` block:
```gradle theme={null}
implementation('androidx.browser:browser:1.5.0')
```
For `androidx.browser:browser:1.5.0`, **Target API Level** must be at least 33. Set it under **Edit** → **Project Settings** → **Player** → **Android** → **Other Settings** → **Target API Level**.
### Manual Manifest Configuration (Advanced)
If automatic merging doesn't work for your project, you can manually configure `AndroidManifest.xml`:
```xml theme={null}
```
### Platform Requirements
* **iOS Version**: 15.2 or later
### URL Scheme Configuration
Add your custom URL scheme to `Info.plist`:
```xml theme={null}
CFBundleURLTypes
CFBundleURLSchemes
mygame
```
Or configure in Unity:
1. Go to **Edit** → **Project Settings** → **Player** → **iOS** → **Other Settings** → **Supported URL schemes**
2. Increase **Size** by 1
3. Enter your URL scheme in the new **Element** slot (e.g. `mygame` for `mygame://callback`)
The SDK handles deep links automatically. Ensure your redirect URIs match your configured URL scheme.
Your redirect URIs (like `mygame://callback`) must use the same URL scheme that you configure in your platform settings:
* **iOS**: Only the scheme needs to match (e.g., `mygame` in `mygame://callback`)
* **Android**: Both the scheme AND host must match (e.g., `mygame` and `callback` in `mygame://callback`)
See [Initialize Passport](/docs/products/passport/authentication#initialize-passport) for authentication setup.
```csharp theme={null}
// Must match your URL scheme
passport = await Passport.Init
(
clientId: "YOUR_CLIENT_ID",
environment: Environment.Sandbox,
redirectUri: "mygame://callback", // ← mygame matches URL scheme config
logoutRedirectUri: "mygame://logout"
);
```
## Next Steps
### Getting Started
Set up credentials and OAuth client in Immutable Hub
Initialize Passport and implement login
Access wallet addresses and balances
Understand SDK internals and custom engine integration
### Additional Resources
Custom WebView, IL2CPP support
Common questions and troubleshooting
SDK source code and samples
# FAQ
Source: https://docs.immutable.com/docs/sdks/unreal/faq
# Frequently Asked Questions
Common questions and solutions for authentication, BLUI, platform issues, and integration with the Unreal SDK.
## Platform & Technical Setup
As of 6 May 2025, PKCE is supported on Windows (alpha release). Prior to this date, PKCE was only available on Android, iOS, and macOS.
| Date | Event |
| ------------------ | -------------------------------- |
| Before May 6, 2025 | PKCE only on Android, iOS, macOS |
| May 6, 2025 | PKCE Windows support (alpha) |
| July 1, 2025 | Device Code Authorization sunset |
Device Code Authorization will be sunset on July 1, 2025 across all platforms. Migrate to PKCE before this date.
We strongly recommend developers begin migrating to PKCE to ensure a smooth transition before the sunset date.
Both BLUI and Epic's WebBrowserWidget plugin utilise the Chromium Embedded Framework (CEF) in the background. The Immutable plugin requires CEF to run its internal libraries, including player/gamer authentication procedures. In Unreal Engine 4, the CEF libraries are outdated and cannot execute the source code of the internal libraries. Therefore, we require the BLUI plugin to be used instead of the WebBrowserWidget plugin.
| Platform | CEF Version Needed | UE4 WebBrowserWidget | BLUI Plugin |
| --------- | ------------------ | -------------------- | ------------ |
| Windows | CEF 90+ | ❌ Outdated | ✅ Up-to-date |
| Android | N/A | ✅ Native WebView | N/A |
| iOS/macOS | N/A | ✅ WKWebView | N/A |
Unreal Engine 5 may have updated CEF libraries. Check your UE version's CEF version if using UE5.
This issue is known and occurs with Unreal Engine 4.26 and 4.27. If you are having trouble running the [Xsolla](https://github.com/xsolla/store-ue4-sdk) plugin with the Immutable Unreal SDK and [BLUI](https://github.com/immutable/BLUI-Unreal/tree/Imtbl4.2) plugins enabled, it is because of a conflict between Chromium Embedded Framework browser processes.
Our Immutable plugin needs the [BLUI](https://github.com/immutable/BLUI-Unreal/tree/Imtbl4.2) plugin to be enabled (see the "Why do I need to disable the WebBrowserWidget plugin to use BLUI?" section above). However, the [Xsolla](https://github.com/xsolla/store-ue4-sdk) plugin also requires Chromium Embedded Framework to run and relies on Epic's WebBrowser module for that, which leads to conflicts when both plugins are used together.
| Plugin | CEF Requirement | Implementation |
| ------------- | ---------------------- | -------------------------- |
| Immutable SDK | Requires BLUI | Uses BLUI's CEF |
| Xsolla SDK | Requires WebBrowser | Uses Epic's WebBrowser CEF |
| Result | ❌ Both plugins enabled | CEF process conflict |
This is a known limitation with UE 4.26 and 4.27. No perfect solution exists without engine or plugin modifications.
**Workarounds:**
1. Use only one plugin at a time (not ideal for production)
2. Upgrade to Unreal Engine 5 (may resolve CEF conflicts)
3. Contact Xsolla support for alternative integration methods
## Regional Support
The Immutable Game SDK supports only the regions that Immutable Passport supports.
Passport is a globally available product. However, our wallet infrastructure is subject to the regulation of the US Department of the Treasury's Office of Foreign Assets Control ("OFAC"). OFAC administers and enforces comprehensive and targeted economic and trade sanctions programs on multiple countries and regions.
Users attempting to access Passport in any of the regions under OFAC sanction will have their access blocked and will be unable to use our product. Additionally, components of Passport's infrastructure also rely on technology provided by [Magic](https://magic.link/), which maintains further details regarding unsupported regions on their website [here](https://help.magic.link/knowledge/prohibited-regions).
## Authentication
This is not possible due to security reasons. Vendors like Google also block login through webviews because the hosting applications can steal credentials.
Never use WebView for authentication. It's a security risk and violates OAuth best practices.
See the "Why is the in-app browser used for Passport login on mobile and not a webview?" section below for more details.
The in-app browser is specifically designed for single sign-on (SSO) purposes, making it a much more secure option. This browser runs on a separate process from the hosting game, meaning the game cannot access it, modify any content, or inject malicious code.
| Feature | In-App Browser | WebView |
| -------------------------- | ------------------ | --------------------- |
| Process isolation | ✅ Separate process | ❌ Same process as app |
| App can inject code | ❌ No | ✅ Yes (security risk) |
| App can intercept requests | ❌ No | ✅ Yes (security risk) |
| Designed for SSO | ✅ Yes | ❌ No |
WebView gives the hosting game full control, allowing it to intercept requests and inject JavaScript. This is why OAuth providers like Google block WebView authentication.
**Platform implementations:**
* Android: [Chrome Custom Tabs](https://developer.chrome.com/docs/android/custom-tabs)
* iOS/macOS: [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession)
To securely implement PKCE, we must use `ASWebAuthenticationSession`, which shows an alert. This alert cannot be removed or modified as the operating system triggers it.
This is a security feature designed to protect users from unauthorized authentication attempts. Any attempt to bypass it will cause app rejection.
[Learn more about ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession)
This error occurs because you are attempting to log in using a WebView instead of a browser. Google blocks authentication attempts from WebViews because they can be exploited to steal user credentials. The hosting app has full control over WebView content, including the ability to inject JavaScript and intercept requests.
Use the system browser or in-app browser (SFSafariViewController/Chrome Custom Tabs) instead of WebView. The SDK uses the system browser by default.
Refer to the "Can I use a WebView instead of the system browser for login?" section above for more information.
Custom URL schemes are used because [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) doesn't reliably trigger Universal Links after authentication. Universal Links depend on Safari's verification process, which can be inconsistent. Custom URL schemes ensure immediate redirection to the app and a reliable experience.
This approach is secure because [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession) ensures that only the calling app receives the callback even if other apps register the same custom URL scheme. This prevents callback interception by other apps and ensures the security of the authentication process.
## Integration & Usage
You can use the zkEVM Send Transaction function in the SDK to call your smart contract crafting function.
Crafting typically involves calling a smart contract that burns input NFTs and mints a new crafted NFT.
Yes! The Unity and Unreal SDKs share a common architecture that you can adapt for custom game engines.
See the [Game Bridge Architecture](/docs/sdks/game-bridge-architecture) documentation for more details.
## Still Need Help?
If your question isn't answered here, check the [Unreal SDK overview](/docs/sdks/unreal/overview) for implementation guides.
## Next Steps
Return to SDK documentation
Learn about Passport authentication
Manage publishable and secret keys
Integrate wallet funding
# Unreal SDK
Source: https://docs.immutable.com/docs/sdks/unreal/overview
Native C++ and Blueprint support for Passport authentication, wallet operations, and blockchain data queries.
**Who is this for?** Game developers using Unreal Engine who want to integrate web3 features, NFTs, and blockchain functionality into their games.
## Platform Compatibility
| Unreal Engine Version | Windows | macOS | iOS | Android |
| --------------------- | --------------- | --------------- | --------------- | --------------- |
| **5.5.x** | ✅ Full support | ✅ Full support | ⚠️ Beta | ⚠️ Beta |
| **5.4.x** | ✅ Full support | ✅ Full support | ⚠️ Beta | ⚠️ Beta |
| **5.3.x** | ✅ Full support | ✅ Full support | ⚠️ Beta | ⚠️ Beta |
| **5.2.x and below** | ❌ Not supported | ❌ Not supported | ❌ Not supported | ❌ Not supported |
**Platform Requirements**: Requirements depend on your Unreal Engine version. Use the version selector in Unreal's documentation to view requirements for your engine:
* [Windows](https://dev.epicgames.com/documentation/en-us/unreal-engine/setting-up-visual-studio-development-environment-for-cplusplus-projects-in-unreal-engine)
* [macOS](https://dev.epicgames.com/documentation/en-us/unreal-engine/macos-development-requirements-for-unreal-engine)
* [Android](https://dev.epicgames.com/documentation/en-us/unreal-engine/android-support-for-unreal-engine)
* [iOS](https://dev.epicgames.com/documentation/en-us/unreal-engine/setting-up-an-unreal-engine-project-for-ios)
**Legacy Versions (4.26, 4.27, 5.0)**: These versions are **not officially supported** and require the BLUI plugin workaround. We strongly recommend using **UE 5.3+** for new projects. See the **Legacy Versions** accordion below.
**Mobile Platforms (iOS & Android)**: Not officially supported.
## Prerequisites
* **Unreal Engine**: 5.3 or later (5.4 and 5.5 recommended)
* **Git LFS**: The SDK repository uses [Git Large File Storage](https://git-lfs.github.com/) to manage `.uasset` and `.umap` files. Install Git LFS before cloning or the plugin may fail to download binary dependencies correctly.
## Installation
1. Install [Git LFS](https://git-lfs.github.com/)
Git LFS must be installed **before** adding the SDK. The SDK contains `.uasset` and `.umap` files stored in Git LFS that won't download correctly otherwise.
2. Add the SDK to your project's `Plugins` directory:
From your project's root directory:
```bash theme={null}
git clone https://github.com/immutable/unreal-immutable-sdk.git Plugins/unreal-immutable-sdk
```
From your project's root directory:
```bash theme={null}
git submodule add https://github.com/immutable/unreal-immutable-sdk.git Plugins/unreal-immutable-sdk
```
1. Download from [GitHub Releases](https://github.com/immutable/unreal-immutable-sdk/releases)
2. Extract the archive
3. Copy to your project's `Plugins/unreal-immutable-sdk` folder
3. Restart the Unreal Editor
**Legacy Unreal Engine Versions (4.26, 4.27, 5.0)**: Additional BLUI plugin installation required. See [Legacy Versions Setup](/docs/sdks/unreal/overview#platform-specific-configuration) in the overview.
Your project structure should look like:
```
Root/
└── Plugins/
└── unreal-immutable-sdk/
```
4. Enable the Immutable module in your Build.cs:
```cpp title="Source/YourGame/YourGame.Build.cs" theme={null}
PublicDependencyModuleNames.AddRange(new string[] {
"Core", "CoreUObject", "Engine", "InputCore",
"Immutable" // Add this
});
```
For mobile platforms, see [Platform Setup](/docs/sdks/unreal/overview#platform-specific-configuration).
**Not Officially Supported**: Unreal Engine 4.26, 4.27, and 5.0 are not officially supported. We strongly recommend using UE 5.3+ for new projects.
For Unreal Engine 4.26, 4.27, and 5.0, you must install the BLUI plugin because Epic's `WebBrowserWidget` uses an outdated CEF version incompatible with the SDK.
More on why we use BLUI plugin can be found in the [FAQ](/docs/sdks/unreal/faq).
**1. Download BLUI**
Download or clone from [immutable-BLUI](https://github.com/immutable/immutable-BLUI) to your project's `Plugins` folder.
**2. Rename the folder**
Rename `immutable-BLUI` to `BLUI`. Your plugins directory should look like:
```
MyGame/
└── Plugins/
├── BLUI/
└── unreal-immutable-sdk/
```
**3. Disable WebBrowserWidget**
Edit `unreal-immutable-sdk/Immutable.uplugin` and set `WebBrowserWidget` to disabled:
```json theme={null}
{
"Plugins": [
{
"Name": "WebBrowserWidget",
"Enabled": false
}
]
}
```
**4. Configure Hub**
**Hub Configuration Required**: In [Immutable Hub](https://hub.immutable.com), you MUST add `file://*` to the **Web Origin URLs** field in your Passport client configuration. Without this, the BLUI embedded browser cannot communicate with the Passport SDK and authentication will fail.
1. Navigate to your project in Immutable Hub
2. Go to **Passport** → **Clients** → Select your client
3. Add `file://*` to the **Web Origin URLs** field
4. Save changes
## Platform-Specific Configuration
**Unreal Engine 5.3+**: This version uses the [Modern Xcode workflow](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-modern-xcode-in-unreal-engine) with per-project configuration files. Follow the platform-specific instructions below for your target platforms.
### Platform Requirements
* **Windows**: 10 or later
No additional configuration required for Windows builds.
### Platform Requirements
* **macOS**: 10.15 or later
### URL Scheme Configuration
1. Locate your project's `.plist` template file at `/Build/Mac/Resources/Info.Template.plist`
* You can find the path in **Mac: Info.plist Template** in Project Settings (shows as `/Game/Build/Mac/Resources/Info.Template.plist`)
* If it doesn't exist, Unreal will create it when you first generate project files
2. Add the following inside the root `... `:
```xml theme={null}
CFBundleURLTypes
CFBundleURLSchemes
mygame
```
**Using a custom file**: You can create your own `.plist` file and update **Mac: Info.plist Template** in Project Settings to point to your custom file for more control.
Editing the engine's `Info.plist` at `Engine/Source/Runtime/Launch/Resources/Mac/Info.plist` will affect all projects using that engine installation. Use project-specific `.plist` files instead. See [Epic's Modern Xcode docs](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-modern-xcode-in-unreal-engine#.plistfiles) for details.
### Platform Requirements
* **Minimum Android Version**: 10 or later (API Level 24+)
* **Target API**: 33 or higher recommended
### Deep Link Configuration
1. Create `Source//_UPL_Android.xml`:
```xml theme={null}
```
2. Modify `Source//.Build.cs`:
```csharp theme={null}
public class YourProject : ModuleRules
{
public YourProject(ReadOnlyTargetRules Target) : base(Target)
{
if (Target.Platform == UnrealTargetPlatform.Android)
{
string PluginPath = Utils.MakePathRelativeTo(ModuleDirectory, Target.RelativeEnginePath);
AdditionalPropertiesForReceipt.Add("AndroidPlugin", Path.Combine(PluginPath, "YourProject_UPL_Android.xml"));
}
}
}
```
### Platform Requirements
* **iOS Version**: 15.2 or later
### URL Scheme Configuration
1. Go to **Project Settings** → **Platforms** → **iOS**
2. Under **Extra PList Data**, add:
```xml theme={null}
CFBundleURLTypes
CFBundleURLSchemes
mygame
```
1. Locate your project's `.plist` template file at `/Build/IOS/UBTGenerated/Info.Template.plist`
* You can find the path in **IOS / TVOS: Info.plist Template** in Project Settings (shows as `/Game/Build/IOS/UBTGenerated/Info.Template.plist`)
2. Add the following inside the root `... `:
```xml theme={null}
CFBundleURLTypes
CFBundleURLSchemes
mygame
```
**Using a custom file**: If you want to avoid UBT potentially overwriting the default template, create your own `.plist` file (you can use `Info.Template.plist` as a starting point) and update **IOS / TVOS: Info.plist Template** in Project Settings to point to your custom file.
UBT auto-generates the default iOS template and may overwrite changes. See [Epic's Modern Xcode docs](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-modern-xcode-in-unreal-engine#.plistfiles) for details.
1. Create a complete `.plist` file at `/Build/IOS/Resources/MyGameIOS.plist`
2. Add your URL scheme and all required iOS app settings:
```xml theme={null}
CFBundleURLTypes
CFBundleURLSchemes
mygame
```
3. Edit `/Config/DefaultEngine.ini` and add:
```ini theme={null}
[/Script/MacTargetPlatform.XcodeProjectSettings]
PremadeIOSPlist=(FilePath="/Game/Build/IOS/Resources/MyGameIOS.plist")
```
**Premade vs Template**: A premade `.plist` is a complete, finalized file that Xcode will NOT modify. This gives you absolute control but requires managing all iOS app settings yourself. See [Epic's Modern Xcode docs](https://dev.epicgames.com/documentation/en-us/unreal-engine/using-modern-xcode-in-unreal-engine#useapremade.plist) for details.
The SDK handles deep links automatically. Ensure your redirect URIs match your configured URL scheme.
Your redirect URIs (like `mygame://callback`) must use the same URL scheme that you configure in your platform settings:
* **iOS**: Only the scheme needs to match (e.g., `mygame` in `mygame://callback`)
* **Android**: Both the scheme AND host must match (e.g., `mygame` and `callback` in `mygame://callback`)
See [Initialize Passport](/docs/products/passport/authentication#initialize-passport) for authentication setup.
```cpp theme={null}
auto InitData = FImmutablePassportInitData
{
TEXT("YOUR_CLIENT_ID"),
TEXT("mygame://callback"),
TEXT("mygame://logout"),
TEXT("sandbox"),
...
};
Passport->Initialize(InitData, ...);
```
## Next Steps
### Getting Started
Set up credentials and OAuth client in Immutable Hub
Initialize Passport and implement login
Access wallet addresses and balances
Understand SDK internals and custom engine integration
### Additional Resources
Common questions and troubleshooting
SDK source code and examples