Skip to main content
Mint NFTs at scale without managing nonces, batching, gas, or indexing—everything is handled through a single API call.

Why Use the Minting API?

FeatureBenefit
Managed InfrastructureNo nonce tracking, transaction lifecycle, or gas management
Batch OptimizationMultiple mint requests combined into gas-efficient transactions
Metadata IndexingMetadata indexed immediately without crawling your baseURI
Rate Limit HandlingBuilt-in queuing with clear rate limit feedback
Idempotent RequestsSafe to retry with same reference_id
The Minting API is a REST API. See the endpoint examples below for request/response formats.

Prerequisites

The Minting API requires Immutable preset contracts. Custom contracts are not supported.
Security: Keep your Secret API Key server-side only. Never expose it in client code. See API Keys Security for best practices.

Rate Limits

TierNFTs/SFTs per minuteBurstUse Case
Standard2002,000Testing, small mints
Partner2,00020,000Production games
EnterpriseCustomCustomHigh-volume launches
Rate limits count distinct token_ids, not total tokens. Minting 1000 copies of the same ERC-1155 token_id counts as 1 request.
Rate limit headers in response:
{
  "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_ids (e.g., migrating existing assets):
// 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_ids:
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

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

AspectERC-1155
token_idAlways required
amountRequired (number of tokens)
MetadataOnly on first mint for a token_id
Duplicate token_idAllowed (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

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<MintRequestStatus> {
  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<MintRequestStatus> {
  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

StatusMeaning
pendingRequest queued, waiting for blockchain confirmation
succeededMint completed, token_id and transaction_hash available
failedMint failed, see error field
Configure webhooks in Hub to receive real-time updates:
// 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 metadata in the mint request for immediate indexing:
{
  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.

Metadata Format

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 CaseDescription
Leveling UpCharacter gains experience or unlocks new abilities
CraftingItems are combined or upgraded
RevealsDelayed reveal for primary sales or mystery boxes
Balance UpdatesAdjusting game stats or rarity after launch
Content FixesCorrecting art, names, or descriptions

Refresh by Token ID

Push updated metadata for specific tokens:
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

ResourceLimit
Total NFTs updated677 per minute
Token IDs per requestUp to 250
Total metadata size228 KiB per request
Metadata stacks (by metadata_id)677 per minute, up to 10 IDs per request
Rate limit response headers:
{
  "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 to confirm updates:
// 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)

ErrorCodeCause
VALIDATION_ERROR400>100 assets, duplicate reference_id, invalid format
UNAUTHORISED_REQUEST401Invalid API key
AUTHENTICATION_ERROR403Collection not linked to API key
CONFLICT409reference_id or token_id already used
TOO_MANY_REQUESTS_ERROR429Rate limit exceeded

Transaction Errors (Check via Status API)

ErrorCauseSolution
AccessControl: account ... is missing roleMinter role not grantedGrant minter role to Immutable’s minting wallet
Exceeded maximum submissionsTransaction retry failedResubmit the request

Idempotent Requests

Safe to retry failed network requests—duplicate reference_id returns 409 Conflict:
try {
  await mintRequest(assets);
} catch (error) {
  if (error.status === 409) {
    // Already processed - check status instead
    const status = await getMintStatus(referenceId);
  }
}

Best Practices

Batch Efficiently

// ✅ 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

// ✅ Good: Meaningful reference_id
reference_id: `reward-${eventId}-${playerId}`

// ❌ Bad: Random reference_id (hard to debug)
reference_id: crypto.randomUUID()

Handle Rate Limits

// 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