After creating listings or reviewing filled 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 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:
5-Step Trade Flow
- Buyer selects order - User chooses a listing via marketplace
- Orderbook prepares payload - Immutable generates transaction details for settlement contract
- Buyer signs transaction - User reviews and signs
- Transaction submitted - Signed transaction sent to settlement contract
- 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 documentation.
Soft Cancel (Gasless)
Soft cancellation is free and instant—the order is marked as cancelled off-chain, preventing new fulfillments.
How It Works
- User signs a message proving they own the order (EIP-712)
- Orderbook marks order as cancelled
- Orderbook stops generating transaction details for this order
- Order becomes unfillable by new buyers
Basic Example
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:
{
"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_cancellationsOrders 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:
- Buyer started fulfillment process before your cancel
- Buyer received transaction details (Step 2) before cancel
- Buyer has 90 seconds to submit the signed transaction
If you see orders in pending_cancellations, consider using a hard cancel for guaranteed cancellation.
The 90-Second Race Condition
Critical: Race Condition WindowThe 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:
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.
// 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
- User sends transaction to settlement contract
- Contract adds order to blacklist
- Any future fulfillment attempts for this order will fail on-chain
- 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 |
Basic Example
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:
{
"type": 2,
"chainId": 31337,
"to": "0x0165878A594ca255338adfa4d48449f69242Eb8F", // Settlement contract
"data": "0xfd9f1e14f7f8cb7730d62bf4b15ecff270857...",
"gasLimit": { "hex": "0xd821" },
"hash": "0x3ad4833ff47ddef5982746935cdcf555631676e097e4e64218c593664f478e7a"
}
Validating Hard Cancel
Poll the order after transaction confirms:
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:
import { config, orderbook } from '@imtbl/sdk';
import { ethers } from 'ethers';
async function cancelOrderFlow(
orderIds: string[],
cancelType: 'soft' | 'hard' = 'soft'
) {
<SdkInit />
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:
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
- Check
pending_cancellations: Warn users about race conditions
- Show gas estimates: For hard cancels, display gas cost before execution
- Poll for status: Don’t assume immediate cancellation
- Batch cancels: Group multiple soft cancels into one request (max 20)
- Upgrade path: Offer hard cancel if soft cancel returns pending
- Expiration check: If order expires soon, soft cancel sufficient
- 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:
- Incentivize
Validators: Pays network validators to include your transaction
- 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