Skip to main content
Sell NFTs with payment collection, order verification, and minting—all in a single widget. Primary Sales 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

Setup

1

Configure in Hub

Go to HubPrimary Sales to deploy a Primary Sales contract, configure payment currencies and payout addresses, and choose between Simplified or Advanced mode.
2

Integrate the widget

Mount the Sale widget in your frontend to handle the checkout flow.
3

Handle backend (Advanced mode only)

Implement webhook endpoints if using Advanced mode for dynamic pricing or custom logic.

Widget integration

npm install @imtbl/sdk
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',
  },
});
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

ParameterTypeDescription
flowCommerceFlowType.SALERequired. Selects the sale flow.
itemsSaleItem[]Items to purchase. Each item requires productId, qty, name, image, and description.
environmentIdstringYour 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.
collectionNamestringCollection name shown in the widget UI. Optional in the type, but expected in practice.
excludePaymentTypesSalePaymentTypes[]Payment methods to hide: crypto, debit, credit.
preferredCurrencystringOverrides the backend’s base settlement currency.
excludeFiatCurrenciesstring[]Fiat currencies to exclude from the on-ramp.
customOrderDataRecord<string, unknown>Custom key-value pairs attached to the order.
walletProviderNameWalletProviderNameDefault 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).
EventDescription
SUCCESSA success step completed. For CommerceSuccessEventType.SALE_SUCCESS, payload.data is { paymentMethod, tokenIds, transactions: [{ method, hash }], transactionId }.
FAILUREA step failed. For CommerceFailureEventType.SALE_FAILED, payload.data is { reason, error, timestamp, paymentMethod, transactions, transactionId }.
CLOSEUser 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 APIList 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 productUpserts 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
FieldTypeRequiredDescription
product_idstringYesStable catalog identifier (for example starter-pack).
namestringYesDisplay name.
imagestringYesURL for product art or icon.
descriptionstringNoLonger description shown in UIs that support it.
statusstringYesactive or inactive. Inactive products cannot be purchased in Simplified mode.
quantityintegerYesStock available for sale (inventory).
pricingarrayYesOne or more price rows (see Pricing below). At least one entry is required.
collectionobjectYesWhich collection and token rules apply at mint (see Collection below).
limitsobjectNoOptional per-buyer caps (see Limits below).
Pricing (pricing[])Each element defines a price in one currency:
FieldTypeRequiredDescription
currencystringYesCurrency code (for example USDC), aligned with currencies configured for Primary Sales.
amountnumberYesUnit price in that currency for a single item.
Collection (collection)
FieldTypeRequiredDescription
collection_typestringYesERC721 or ERC1155. Must match how the linked collection was deployed.
collection_addressstringYesNFT collection contract on chain (same collection linked to Primary Sales in Hub).
token_idstringERC-1155 onlyFixed token ID for this product. Required for ERC-1155. Omit or leave unset for ERC-721 (mints use generated IDs).
Limits (limits)
FieldTypeRequiredDescription
enabledbooleanYes if limits is sentWhen true, per-recipient limits apply.
max_per_recipient_addressintegerIf enabledMaximum units one wallet may purchase; must be greater than zero when limits are enabled.
Example (ERC-721)
{
  "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 productRemoves 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
AuthenticationListing products is public; create, update, and delete require authentication. Authentication requires either:
MethodHeaderWhen to use
Bearer tokenAuthorization: Bearer <access_token>Used when managing the product catalogue via Hub
Secret API keyx-immutable-api-key: <secret_key>Server-side scripts, automation, or backends using a secret API key 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 for imtbl_zkevm_activity_mint events, then generate metadata and call the Metadata Refresh API

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:
{
  "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:
{
  "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.
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:
{
  "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:
{
  "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.
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:
{
  "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:
{
  "reference": "order-123",
  "confirmed": true
}
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:
{
  "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:
{
  "reference": "order-123",
  "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:
// 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, bridge, or fiat onramp options.

Fiat

Card payments are handled through Transak, 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

Minting API

Mint NFTs from your backend

Hub Webhooks

Configure webhooks for mint events

ERC-721 Contracts

Deploy NFT collections

Orderbook

Enable secondary trading