Left-facing arrow
back to blog

How To Keep Your NFTs Secret Until Minted

Reveal-on-Mint NFTs

Oh cool, you’re making an NFT project!

Let’s talk about keeping your NFTs secret until they’re minted, and how to accomplish it.

Reveal-on-mint is the concept of “revealing” (to the internet/world) the metadata and digital art (static image, video clip, whatever) of a specific NFT, only after that NFT has been bought and paid for on-chain. Big surprise huh.


  • You already have all of the art for the project created and uploaded to a private S3 bucket
  • You have a JSON file full of metadata for your NFTs
  • You have a server to run the Metadata API on. This server is where the reveal logic lives
  • You have a smart contract that looks a little something like this:
-- CODE language-shell -- // SPDX-License-Identifier: GPL-3.0 pragma solidity >=0.7.0 <0.9.0; import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.3.1/contracts/token/ERC721/ERC721.sol"; contract MassiveManatees is ERC721("Massive Manatees", "MM") { uint256 immutable public price = 0.01 ether; uint8 immutable public maxId = 255; uint8 public currentId = 0; event Mint(address account, uint8 id); function _baseURI() internal pure override returns (string memory) { return "https://api.massivemanatees.com/manatees/"; } function mint() public payable returns (uint8 id) { require(msg.value == price, "pay the man"); require(currentId <= maxId, "minting over"); id = currentId; currentId++; _safeMint(msg.sender, id); emit Mint(msg.sender, id); } }

Things to notice in the above contract:

  • hardcoded “price” for each mint
  • immutable number representing the max number of token ids that can be minted (256 total in the above example, from ids 0 thru 255)
  • variable that keeps track of the most recently minted id
  • a baseURI, which the imported OpenZeppelin contract will use to tell callers where metadata for each token lives
  • a mint function, with some basic sanity checks, that increments our “current id” counter and mints a new NFT to the msg.sender

We’ll build an <code>express<code> server in NodeJS to implement our revealing logic.
The idea is simple.

We are building a “metadata / image API”, which obviously can accept requests from anywhere, such as OpenSea or our own web app. The API technically has access to the full set of images and metadata, as all of those were generated ahead of time. We can write logic to intelligently return actual data or an error message, for any request, based on the most recently minted ID.

The following code exports a function which has an ethers.js contract object as an input parameter, and returns an express object. The returned express object from this code should be used to start up an http server.

-- CODE language-shell -- const express = require('express'); const cors = require('cors'); const sharp = require('sharp'); const s3 = new (require('aws-sdk')).S3; const metadata = require('./metadata.json'); require("dotenv").config(); const app = async (contract) => { // at service startup, what is the most recently minted let currentMaxId = await contract.currentId(); // update that currentMaxId when `Mint` events // are fired from the contract contract.on("Mint", (_, tokenId) => { currentMaxId = tokenId.toNumber(); }); // simple helper function that route handlers use to // determine if responses should be real data, or error function checkApproval(tokenId) { return currentMaxId >= tokenId; }; const application = express(); application.use(cors()); // ROUTE HANDLER application.get('/metadata/:w', (req, res) => { const tokenId = req.params.w; if (checkApproval(tokenId)) { meta = metadata[parseInt(tokenId) - 1]; meta.image = `http://${req.headers.host}/images/${tokenId}`; meta.external_url = `https://massivemanatees.com/manatees/${tokenId}`; res.json(meta); } else { res.status(401); res.json({ error: "manatee not minted" }); } }); // ROUTE HANDLER application.get('/images/:w', (req, res) => { const tokenId = req.params.w; if (checkApproval(tokenId)) { const getParams = { Bucket: process.env.BUCKET_NAME, Key: `${req.params.w}.png`, }; s3.getObject(getParams, function (err, data) { if (err) { res.status(500); res.json(err); return; } let img = data.Body.toString('base64'); img = Buffer.from(img, 'base64'); sharp(img) .resize(350, 350, { kernel: 'nearest' }) .toBuffer() .then(resizedImageBuffer => { let resizedImageData = resizedImageBuffer.toString('base64'); resizedImageData = Buffer.from(resizedImageData, 'base64'); res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': resizedImageData.length }); res.end(resizedImageData); }) .catch(error => { res.status(500); res.json(error); }) }); } else { res.status(401); res.json({ error: "manatee not minted" }); } }); return application; } module.exports = app;

If you’re familiar with express apps, you'll recognize the last two “sections” of this code. They are simple middleware functions attached to the express object, which act as the routes for getting metadata and getting images.

The first few lines are interesting.

First, we read the most currently minted token ID from the contract, and save that in a variable in memory. (We’re doing this without a database yay)

Then, we create an event listener for the Mint event, with a callback handler function that simply updates that in-memory variable with the new ID. That’s the sauce right there. As people mint tokens, our API passively receives events and updates its own state to reflect the most recently minted ID.

Finally, we create a simple helper function called checkApproval which accepts a tokenId, and returns true or false indicating whether or not this ID has been minted or not.

In our request handlers, we extract the tokenId for the request from the URL, pass that into checkApproval, then respond appropriately with actual data, or error messages.

Now go get that project launched, King 👑 

Adam Gall

Build with Us

We are always interested in connecting with people who want to fund, innovate
or work in the decentralized economy.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.