Skip to content

Mint an NFT end-to-end

This guide walks the complete flow from nothing to a minted token, using only the SDK. Every step is copy-pasteable against api.highlightv2.xyz on a testnet chain.

For a quick-fire first call, use the developer quick start. Come back here when you need to see the whole pipeline.

A public, fixed-price Open Edition on Base Sepolia, with one image, deployed end-to-end, ready for a collector to claim. ~100 lines of code.

  • Node 20+ or Bun 1.1+
  • A wallet (viem Account) with some testnet ETH on your target chain
  • The API base URL: https://api.highlightv2.xyz
Terminal window
bun add @highlightxyz/sdk viem

The full auth details live in reference/auth. Short version: SIWE gives you a 15-minute JWT.

import { createHighlightClient, MediaKind } from "@highlightxyz/sdk";
import { createSiweMessage } from "viem/siwe";
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const baseUrl = "https://api.highlightv2.xyz";
const anon = createHighlightClient({ baseUrl });
const { nonce } = (await anon.user.siwe.nonce()).data!;
const message = createSiweMessage({
address: account.address,
chainId: 1,
domain: new URL(baseUrl).host,
nonce,
uri: baseUrl,
version: "1",
issuedAt: new Date(),
expirationTime: new Date(Date.now() + 10 * 60 * 1000),
});
const signature = await account.signMessage({ message });
const { tokens } = (await anon.user.signin({
body: { providerType: "Siwe", message, signature },
})).data!;
const client = createHighlightClient({
baseUrl,
auth: () => tokens.accessToken,
security: [{ type: "http", scheme: "bearer" }],
});

From here on, client is authenticated.

Media upload is a three-step dance: create a session, PUT bytes, poll until Ready. See media upload flow for the full reference.

import { readFileSync } from "node:fs";
const buffer = readFileSync("./artwork.png");
const session = (await client.media.createUploadSession({
kind: MediaKind.File,
fileName: "artwork.png",
mimeType: "image/png",
fileSize: buffer.byteLength,
})).data!;
// PUT the bytes. The upload URL is a worker endpoint on the same origin.
await fetch(session.upload.url, {
method: session.upload.method,
headers: {
...session.upload.headers,
authorization: `Bearer ${tokens.accessToken}`,
},
body: buffer,
});
// Poll until Ready
const logoMediaId = session.media.id;
let status = "Pending";
while (status !== "Ready") {
await new Promise((r) => setTimeout(r, 2000));
status = (await client.media.get({ mediaId: logoMediaId })).data!.status;
if (status === "Failed") throw new Error("Media processing failed");
}

The collection starts in Draft. We’ll bind it to a brand-new contract on Base Sepolia (chain 84532).

const collection = (await client.collection.create({
name: "My First Drop",
description: "An open edition minted via the Highlight SDK.",
type: "OpenEdition",
logoMediaId,
image: session.media.url!,
contract: {
chainId: 84532,
name: "MYDROP",
symbol: "MYDROP",
standard: "ERC721",
},
})).data!;
const highlightId = collection.highlightId;

Each collection type has its own PATCH endpoint for type-specific fields. For editions:

await client.collection.updateEditionDetails({
highlightId,
edition: {
size: 0, // 0 = open supply
name: "My First Drop",
description: "An open edition minted via the Highlight SDK.",
imageMediaId: logoMediaId,
attributes: [{ trait_type: "Series", value: "Genesis" }],
},
});

A FIXED_PRICE / PUBLIC sale — the simplest supported combination. See the support matrix for everything else.

const sale = (await client.collection.addSale({
highlightId,
type: "FIXED_PRICE",
accessMode: "PUBLIC",
startAt: new Date().toISOString(),
price: "0.001",
currency: "ETH",
maxPerTransaction: 5,
maxPerWallet: 10,
maxTotal: 1000,
paymentRecipient: account.address,
})).data!;

Deployment is an async workflow. Kick it off, then poll until it needs a wallet signature. See deploy workflow for the full state machine.

import { createWalletClient, http } from "viem";
import { baseSepolia } from "viem/chains";
const walletClient = createWalletClient({
account,
chain: baseSepolia,
transport: http(),
});
await client.collection.deploy({ highlightId });
// Poll until the workflow has a tx ready for us to sign
let deployState = (await client.collection.deployStatus({ highlightId })).data!;
while (deployState.currentStep !== "AwaitTransaction" && deployState.status !== "Completed") {
if (deployState.status === "Failed") throw new Error(deployState.error ?? "deploy failed");
await new Promise((r) => setTimeout(r, 3000));
deployState = (await client.collection.deployStatus({ highlightId })).data!;
}
// Sign & submit the deployment transaction.
// `contractConfig.to` is null for contract-creation transactions — pass undefined to viem.
const cfg = deployState.contractConfig!;
const hash = await walletClient.sendTransaction({
to: (cfg.to ?? undefined) as `0x${string}` | undefined,
data: cfg.data as `0x${string}`,
value: BigInt(cfg.value ?? "0"),
gas: 5_000_000n,
});
// Tell Highlight the txHash; the indexer picks it up from there
await client.collection.deployConfirm({ highlightId, txHash: hash });
// Wait for the collection to go Live
while ((await client.collection.get({ highlightId })).data!.status !== "Live") {
await new Promise((r) => setTimeout(r, 5000));
}

From the collector’s perspective, minting is a single SDK call that returns a contract execution to submit on-chain. For a public fixed-price sale, no executor signature is involved.

const claim = (await client.collection.claimSale({
highlightId,
saleId: sale.id,
amount: 1,
})).data!;
// claim is { chainId, to, data, value } — a flat contract-execution shape.
const mintHash = await walletClient.sendTransaction({
to: (claim.to ?? undefined) as `0x${string}` | undefined,
data: claim.data as `0x${string}`,
value: BigInt(claim.value ?? "0"),
gas: 500_000n,
});

Once the transaction confirms, the indexer picks up the Transfer event, and a Token record appears on the collection.

const tokens = (await client.token.list({ highlightId, limit: 10 })).data!;
console.log(`${tokens.pagination.total} tokens minted so far`);
  • Gated sales — require an allowlist or token ownership. See gated sales.
  • Dutch auctions — scheduled price drops. See sale types.
  • Ranked auctions — sealed-bid, on-chain. See ranked auctions.
  • Series with unique tokens — upload a zip, let collectors choose. See series.
  • Generative — deploy HTML/JS code that renders at mint time. See generative.