Skip to main content
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 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 TypePurposeWhen Needed
APPROVALApprove Seaport to spend ERC-20 tokensFirst time using that currency, or insufficient allowance
FULFILL_ORDERExecute the tradeAlways
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

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:
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 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:
const { actions } = await sdk.fulfillOrder(
  orderId,
  buyerAddress,
  [] // no taker fees
  // amountToFill not specified = full fill
);

Partial Fill

Buy only some of the available items:
// 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:
// 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 OrderThe 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:
// 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

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:
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 documentation.

Fill Status

After fulfillment, check the fill status to see how much has been filled:
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 StatusERC-721ERC-1155 Example
Unfilled0/00/10
Partially filledN/A3/10 (30% filled)
Fully filled1/110/10 (100% filled)
Status Transitions: Orders transition from ACTIVEFILLED 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:
import { config, orderbook } from '@imtbl/sdk';
import { ethers } from 'ethers';

async function completePurchaseFlow(orderId: string, quantity?: string) {
  <SdkInit />

  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:
ErrorCauseSolution
INSUFFICIENT_FUNDSBuyer lacks currencyShow balance, request funding
ORDER_FILLEDOrder already purchasedRefresh listing, show “Sold Out”
ORDER_EXPIREDListing expiredRemove from UI
TRANSACTION_EXPIREDTook longer than 3 minutesCall fulfillOrder() again
APPROVAL_FAILEDUser rejected approvalRetry approval step
INSUFFICIENT_ALLOWANCEApproval amount too lowRequest higher allowance

Checking Token Balance

Before fulfilling orders, validate that buyers have sufficient funds: Use ethers.js to check balance:
// 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