Skip to main content
Guides

NFT Marketplace Integration

Keep your marketplace listings automatically in sync with on-chain state by subscribing to NFT lifecycle events via XRNotify webhooks.

What you'll build

By the end of this guide, your marketplace backend will automatically respond to every stage of an NFT's on-chain lifecycle:

  • Mint — index new NFTs as soon as they appear on-chain
  • Offer created — mark NFTs as "listed for sale" or record incoming bids
  • Offer accepted — transfer ownership and record the sale
  • Offer cancelled — remove stale listings and bids
  • Burn — delist and archive burned tokens

Subscribe to NFT events

Create a single webhook that covers all NFT event types. Because this webhook has no account_filters, it will fire for any NFT activity on the network — suitable for a public marketplace. You can add filters later to narrow scope.

curl -X POST https://api.xrnotify.io/v1/webhooks \
  -H "X-XRNotify-Key: xrn_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.yourmarket.com/webhooks/nft",
    "event_types": [
      "nft.minted",
      "nft.burned",
      "nft.offer_created",
      "nft.offer_accepted",
      "nft.offer_cancelled"
    ]
  }'

Central event dispatcher

A single route handles all NFT events and dispatches to the appropriate handler via a switch on event.event_type.

app.post('/webhooks/nft', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['x-xrnotify-signature'];
  if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);

  // Idempotency guard
  const deliveryId = req.headers['x-xrnotify-delivery-id'];
  if (await cache.get(`delivery:${deliveryId}`)) return res.sendStatus(200);

  switch (event.event_type) {
    case 'nft.minted':       await handleMinted(event);          break;
    case 'nft.offer_created':  await handleOfferCreated(event);   break;
    case 'nft.offer_accepted': await handleOfferAccepted(event);  break;
    case 'nft.offer_cancelled':await handleOfferCancelled(event); break;
    case 'nft.burned':       await handleBurned(event);          break;
  }

  await cache.set(`delivery:${deliveryId}`, 'done', 86400);
  res.sendStatus(200);
});

Handling nft.minted

When an NFT is minted, add it to your index. The issuer is also the initial owner. Decode the uri_decoded field (already hex-decoded by XRNotify) to get the metadata URL.

async function handleMinted(event) {
  const { nft_id, issuer, uri_decoded, flags, taxon } = event.payload;

  await db.nfts.create({
    id: nft_id,
    issuer,
    owner: issuer,         // Owner is issuer on mint
    metadata_url: uri_decoded,
    taxon,
    is_transferable: !(flags & 8), // tfBurnable flag check
    status: 'unlisted',
    created_at: event.timestamp,
  });
}

Handling nft.offer_created

The payload distinguishes between sell offers (listings) and buy offers (bids) via the is_sell_offer boolean.

async function handleOfferCreated(event) {
  const { nft_id, owner, amount, is_sell_offer, offer_id } = event.payload;

  if (is_sell_offer) {
    // Mark the NFT as listed with price and the offer ID
    await db.nfts.update(nft_id, {
      status: 'listed',
      list_price: amount,
      list_offer_id: offer_id,
    });
  } else {
    // Record the incoming bid
    await db.bids.create({
      nft_id,
      bidder: owner,
      amount,
      offer_id,
      created_at: event.timestamp,
    });
  }
}

Handling nft.offer_accepted

A sale is final. Transfer ownership in your database, clear the listing status, and record the sale for provenance.

async function handleOfferAccepted(event) {
  const { nft_id, buyer, seller, price } = event.payload;

  await db.nfts.update(nft_id, {
    owner: buyer,
    status: 'unlisted',
    list_price: null,
    list_offer_id: null,
    last_sale: price,
  });

  await db.sales.create({
    nft_id,
    buyer,
    seller,
    price,
    timestamp: event.timestamp,
  });

  // Remove any outstanding bids now that the NFT has sold
  await db.bids.deleteWhere({ nft_id });
}

Handling nft.offer_cancelled

async function handleOfferCancelled(event) {
  const { nft_id, offer_id, is_sell_offer } = event.payload;

  if (is_sell_offer) {
    // Only unlist if this is the active listing offer
    const nft = await db.nfts.findById(nft_id);
    if (nft?.list_offer_id === offer_id) {
      await db.nfts.update(nft_id, {
        status: 'unlisted',
        list_price: null,
        list_offer_id: null,
      });
    }
  } else {
    await db.bids.deleteWhere({ offer_id });
  }
}

Handling nft.burned

A burned NFT is permanently destroyed. Remove it from active listings and archive any associated bids.

async function handleBurned(event) {
  const { nft_id } = event.payload;

  await db.nfts.update(nft_id, {
    status: 'burned',
    list_price: null,
    list_offer_id: null,
    burned_at: event.timestamp,
  });

  // Remove all bids — there is nothing left to bid on
  await db.bids.deleteWhere({ nft_id });
}

Real-time UI updates

Once your webhook handler updates the database, you can push the change to any connected browser clients so listings update without a page refresh. After calling the relevant db.* method, emit an event on a shared in-process emitter (or a Redis pub/sub channel if you run multiple instances):

// After updating the DB inside handleOfferAccepted:
nftEventEmitter.emit('nft-update', {
  type: 'sold',
  nft_id,
  buyer,
  price,
});

// SSE endpoint your frontend connects to:
app.get('/api/nft-stream/:nftId', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');

  const listener = (update) => {
    if (update.nft_id === req.params.nftId) {
      res.write(`data: ${JSON.stringify(update)}\n\n`);
    }
  };

  nftEventEmitter.on('nft-update', listener);
  req.on('close', () => nftEventEmitter.off('nft-update', listener));
});
For multi-instance deployments, replace the in-process event emitter with a Redis pub/sub channel so all server instances broadcast the update to their connected SSE clients.

Next steps