Supabase Edge Functions for Server-side NFT Minting
deps

Here's a tempting thought: "I'll just call the ForTem API directly from my game client. Simpler code, faster development, ship it."
I have seen this pattern destroy projects.
The moment you embed minting logic in your client, you've handed every player the keys to your treasury. Exposed API keys, unlimited minting, and spoofed ownership claims will cause your carefully designed economy to collapse within hours of launch.
This is Part 3 of the "Building an NFT Marketplace for Your Game" series. Today, we're building the infrastructure that separates amateur implementations from production-ready systems: secure, server-side minting with Supabase Edge Functions.
Security Principle: Never trust the client. Your server is the only place where API keys can be safely stored and where you can enforce business logic before blockchain transactions occur.
Let's build this properly—using actual code from a production game.
A Shortcut for AI-Assisted Development
Before diving into implementation, here's a productivity tip that saved me hours.
ForTem.gg provides an LLM-optimized prompt file designed for AI assistants like Claude, ChatGPT, or Cursor. Instead of manually reading through documentation, you can download this file and feed it directly to your AI coding assistant.
How to get it:
- Visit docs.fortem.gg
- Look for the "Prompt (For LLMs)" download button
- Download the markdown file and attach it to your AI assistant
This document contains complete API endpoint specifications, authentication flow details, and code examples. When I asked Claude to help implement the minting function, I simply attached this file and said: "Build a Supabase Edge Function that mints items using this API." The result was production-ready code in minutes.
What We're Building
Our Edge Function will handle three distinct actions:
| Action | Purpose | When Called |
|---|---|---|
mint | Create a new NFT on ForTem/Sui | Player clicks "Mint Item" |
status | Check if minting is complete | Polling during PROCESSING |
redeem | Verify NFT has been burned for in-game benefits | Player redeems NFT in game |
This single function approach keeps your codebase clean while handling the complete NFT lifecycle.
Environment Variables
First, set up your secrets in Supabase:
# ForTem API configuration
supabase secrets set FORTEM_API_URL=https://testnet-api.fortem.gg
supabase secrets set FORTEM_DEVELOPER_KEY=your_developer_key
supabase secrets set FORTEM_COLLECTION_ID=your_collection_id
# Your game's base URL (for image uploads)
supabase secrets set GAME_BASE_URL=https://your-game.vercel.app| Variable | Description | Example Value |
|---|---|---|
FORTEM_API_URL | ForTem API endpoint | https://testnet-api.fortem.gg |
FORTEM_DEVELOPER_KEY | Your ForTem developer API key | dev_xxxxx |
FORTEM_COLLECTION_ID | Your NFT collection ID | 12345 |
GAME_BASE_URL | Base URL for resolving relative image paths | https://puzzle-pocket.vercel.app |
Understanding ForTem's Two-Step Authentication
ForTem uses a nonce-based authentication that differs from simple API key headers. Every API call requires a fresh access token.
async function getAccessToken(): Promise<string> {
// Step 1: Get nonce
const nonceResponse = await fetch(
`${FORTEM_API_URL}/api/v1/developers/auth/nonce`,
{
method: 'POST',
headers: { 'x-api-key': FORTEM_DEVELOPER_KEY },
}
);
if (!nonceResponse.ok) {
throw new Error(`Failed to get nonce: ${nonceResponse.status}`);
}
const nonceData = await nonceResponse.json();
const nonce = nonceData.data?.nonce;
if (!nonce) {
throw new Error('No nonce received from ForTem API');
}
// Step 2: Exchange nonce for access token
const tokenResponse = await fetch(
`${FORTEM_API_URL}/api/v1/developers/auth/access-token`,
{
method: 'POST',
headers: {
'x-api-key': FORTEM_DEVELOPER_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ nonce }),
}
);
if (!tokenResponse.ok) {
throw new Error(`Failed to get access token: ${tokenResponse.status}`);
}
const tokenData = await tokenResponse.json();
return tokenData.data?.accessToken;
}Critical: Minting tokens are single-use. You must call
getAccessToken()for each minting operation. Reusing tokens will result in authentication failures.
The Complete Edge Function Structure
Here's the overall structure of our Edge Function:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.91.0';
const FORTEM_API_URL = Deno.env.get('FORTEM_API_URL') ?? 'https://testnet-api.fortem.gg';
const FORTEM_DEVELOPER_KEY = Deno.env.get('FORTEM_DEVELOPER_KEY') ?? '';
const FORTEM_COLLECTION_ID = Deno.env.get('FORTEM_COLLECTION_ID') ?? '';
const GAME_BASE_URL = Deno.env.get('GAME_BASE_URL') ?? 'https://your-game.vercel.app';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
interface MintRequest {
action: 'mint' | 'status' | 'redeem';
itemId?: string;
itemName?: string;
rarity?: string;
description?: string;
recipientAddress?: string;
attributes?: Array<{ name: string; value: string }>;
redeemCode?: string;
imageUrl?: string;
}
serve(async (req: Request) => {
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Verify Supabase auth (explained below)
// Parse request body
const body: MintRequest = await req.json();
// Route based on action
switch (body.action) {
case 'mint':
return await mintItem(body);
case 'status':
return await getMintingStatus(body.redeemCode ?? '');
case 'redeem':
return await redeemNFT(body.redeemCode ?? '');
default:
return errorResponse('Invalid action', 400);
}
} catch (error) {
console.error('Function error:', error);
return errorResponse('Internal server error', 500);
}
});Authenticating Requests with Supabase
Every request must include a valid Supabase JWT. Here's how to verify it:
// Inside your serve handler
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(
JSON.stringify({ success: false, error: 'Missing authorization header' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Create Supabase client with user's JWT
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';
const supabase = createClient(supabaseUrl, supabaseAnonKey, {
global: { headers: { Authorization: authHeader } },
});
// Verify the user
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
console.error('Auth error:', authError?.message);
return new Response(
JSON.stringify({ success: false, error: 'Unauthorized' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// user.id is now available for any user-specific logicNote: We're using
SUPABASE_ANON_KEYwith the user's JWT, not the service role key. This ensures we validate the actual user session rather than bypassing auth entirely.
The Mint Action
The mint action creates a new NFT on ForTem. Here's the complete implementation:
function generateRedeemCode(itemId: string, itemType: string): string {
const timestamp = Date.now().toString(36).toUpperCase();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
const typeCode = itemType.substring(0, 3).toUpperCase();
return `PP-${typeCode}-${timestamp}-${random}`;
}
async function mintItem(req: MintRequest): Promise<Response> {
if (!req.itemId || !req.itemName || !req.rarity) {
return new Response(
JSON.stringify({ success: false, error: 'Missing required fields' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
try {
const accessToken = await getAccessToken();
const redeemCode = generateRedeemCode(req.itemId, req.itemType ?? 'ITEM');
// Upload image if provided (explained in next section)
let itemImage: string | undefined;
if (req.imageUrl) {
const imageCid = await uploadImageToFortem(req.imageUrl, accessToken);
if (imageCid) {
itemImage = imageCid;
}
}
// Build mint payload
const mintPayload: Record<string, unknown> = {
name: req.itemName,
quantity: 1,
redeemCode,
description: req.description ?? `${req.itemName} from Puzzle Pocket`,
attributes: req.attributes ?? [
{ name: 'Rarity', value: req.rarity },
{ name: 'Game', value: 'Puzzle Pocket' },
],
recipientAddress: req.recipientAddress,
};
// Add image CID if uploaded successfully
if (itemImage) {
mintPayload.itemImage = itemImage;
}
// Create NFT via ForTem API
const mintResponse = await fetch(
`${FORTEM_API_URL}/api/v1/developers/collections/${FORTEM_COLLECTION_ID}/items`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(mintPayload),
}
);
if (!mintResponse.ok) {
const errorText = await mintResponse.text();
console.error('ForTem mint error:', errorText);
return new Response(
JSON.stringify({ success: false, error: `Minting failed: ${mintResponse.status}` }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const mintData = await mintResponse.json();
return new Response(
JSON.stringify({
success: true,
data: {
itemId: mintData.data?.itemId,
nftNumber: mintData.data?.nftNumber,
redeemCode: mintData.data?.redeemCode ?? redeemCode,
status: mintData.data?.status ?? 'PROCESSING',
},
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Mint error:', error);
return new Response(
JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}Redeem Code Format
The redeem code format PP-${typeCode}-${timestamp}-${random} serves multiple purposes:
- PP: Your game prefix (Puzzle Pocket)
- typeCode: First 3 characters of item type (e.g., SKI for Skin)
- timestamp: Base36-encoded timestamp for uniqueness
- random: Additional randomness to prevent collision
Example: PP-SKI-N3B5F2-A7K9
Uploading Images to ForTem
If your items have custom images, you can upload them to ForTem's IPFS storage:
async function uploadImageToFortem(
imageUrl: string,
accessToken: string
): Promise<string | null> {
try {
// Resolve full URL if relative path
const fullUrl = imageUrl.startsWith('http')
? imageUrl
: `${GAME_BASE_URL}${imageUrl}`;
// Fetch image from URL
const imageResponse = await fetch(fullUrl);
if (!imageResponse.ok) {
console.error('Failed to fetch image:', imageResponse.status);
return null;
}
const imageBlob = await imageResponse.blob();
const imageBuffer = await imageBlob.arrayBuffer();
// Create form data for ForTem upload
const formData = new FormData();
const fileName = imageUrl.split('/').pop() ?? 'item.png';
formData.append('file', new Blob([imageBuffer], { type: 'image/png' }), fileName);
// Upload to ForTem
const uploadResponse = await fetch(
`${FORTEM_API_URL}/api/v1/developers/collections/${FORTEM_COLLECTION_ID}/items/image-upload`,
{
method: 'PUT',
headers: { 'Authorization': `Bearer ${accessToken}` },
body: formData,
}
);
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
console.error('ForTem image upload error:', errorText);
return null;
}
const uploadData = await uploadResponse.json();
return uploadData.data?.itemImage ?? null;
} catch (error) {
console.error('Image upload error:', error);
return null;
}
}Key points:
- Images can be passed as relative paths (e.g.,
/items/golden-paddle.png) or full URLs - The function resolves relative paths using
GAME_BASE_URL - ForTem returns an IPFS CID that you pass to the mint endpoint as
itemImage - If image upload fails, minting continues without a custom image
The Status Action
After minting, the NFT goes through a PROCESSING state. Your game client should poll this endpoint to check completion:
async function getMintingStatus(redeemCode: string): Promise<Response> {
if (!redeemCode) {
return new Response(
JSON.stringify({ success: false, error: 'Missing redeemCode' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
try {
const accessToken = await getAccessToken();
const statusResponse = await fetch(
`${FORTEM_API_URL}/api/v1/developers/collections/${FORTEM_COLLECTION_ID}/items/${redeemCode}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
);
if (!statusResponse.ok) {
if (statusResponse.status === 404) {
return new Response(
JSON.stringify({ success: false, error: 'Item not found' }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({ success: false, error: `Status check failed: ${statusResponse.status}` }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const statusData = await statusResponse.json();
return new Response(
JSON.stringify({
success: true,
data: {
id: statusData.data?.id,
objectId: statusData.data?.objectId,
name: statusData.data?.name,
status: statusData.data?.status,
nftNumber: statusData.data?.nftNumber,
},
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Status check error:', error);
return new Response(
JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}Understanding NFT Status
| Status | Description | Client Action |
|---|---|---|
PROCESSING | NFT is being minted on-chain | Show spinner, poll again |
MINTED | NFT successfully created | Show success, enable trading |
OFFER_PENDING | Trade offer in progress | Item temporarily locked |
KIOSK_LISTED | Listed on marketplace | Available for purchase |
REDEEMED | Burned for in-game benefits | Grant in-game item |
The Redeem Action
When players burn their NFT on ForTem to get in-game benefits, your game needs to verify the redemption:
async function redeemNFT(redeemCode: string): Promise<Response> {
if (!redeemCode) {
return new Response(
JSON.stringify({ success: false, error: 'Missing redeemCode' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
try {
const accessToken = await getAccessToken();
const statusResponse = await fetch(
`${FORTEM_API_URL}/api/v1/developers/collections/${FORTEM_COLLECTION_ID}/items/${redeemCode}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
}
);
if (!statusResponse.ok) {
if (statusResponse.status === 404) {
return new Response(
JSON.stringify({ success: false, error: 'Item not found' }),
{ status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
return new Response(
JSON.stringify({ success: false, error: `Failed to verify: ${statusResponse.status}` }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const itemData = await statusResponse.json();
const status = itemData.data?.status;
// CRITICAL: Only grant items if status is REDEEMED
if (status !== 'REDEEMED') {
return new Response(
JSON.stringify({
success: false,
error: status === 'MINTED' || status === 'KIOSK_LISTED'
? 'Item not yet redeemed. Please redeem on ForTem marketplace first.'
: `Item status is ${status}. Only REDEEMED items can be claimed.`,
}),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Return item data for granting in-game
return new Response(
JSON.stringify({
success: true,
data: {
id: itemData.data?.id,
name: itemData.data?.name,
description: itemData.data?.description,
attributes: itemData.data?.attributes ?? [],
status: itemData.data?.status,
nftNumber: itemData.data?.nftNumber,
},
}),
{ status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Redeem verification error:', error);
return new Response(
JSON.stringify({ success: false, error: error instanceof Error ? error.message : 'Unknown error' }),
{ status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
}Critical Security Check: Always verify
status === 'REDEEMED'before granting in-game items. This confirms the NFT has been burned and cannot be traded again. A malicious player could try to claim benefits while still holding the NFT.
Calling from Your Game Client
Here's how to call the Edge Function from your Phaser game:
// In your game client
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Mint an item
async function mintItem(item: GameItem, walletAddress: string) {
const { data, error } = await supabase.functions.invoke('fortem-mint', {
body: {
action: 'mint',
itemId: item.id,
itemName: item.name,
itemType: item.type,
rarity: item.rarity,
description: item.description,
recipientAddress: walletAddress,
imageUrl: item.imageUrl,
attributes: [
{ name: 'Rarity', value: item.rarity },
{ name: 'Category', value: item.category },
{ name: 'Game', value: 'Puzzle Pocket' },
],
},
});
if (error) throw error;
return data;
}
// Check minting status
async function checkMintStatus(redeemCode: string) {
const { data, error } = await supabase.functions.invoke('fortem-mint', {
body: {
action: 'status',
redeemCode,
},
});
if (error) throw error;
return data;
}
// Verify redemption
async function verifyRedemption(redeemCode: string) {
const { data, error } = await supabase.functions.invoke('fortem-mint', {
body: {
action: 'redeem',
redeemCode,
},
});
if (error) throw error;
return data;
}Testing Locally
# Start local Supabase
supabase start
# Serve the function locally
supabase functions serve fortem-mint --env-file .env.local
# Test mint action
curl -X POST http://localhost:54321/functions/v1/fortem-mint \
-H "Authorization: Bearer YOUR_TEST_JWT" \
-H "Content-Type: application/json" \
-d '{
"action": "mint",
"itemId": "golden-paddle",
"itemName": "Golden Paddle",
"rarity": "Epic",
"recipientAddress": "0x904bb53d5508de51fdf1d3c3960fd597e52cb39ae11c562ca22f1acbb2702d8b"
}'
# Test status check
curl -X POST http://localhost:54321/functions/v1/fortem-mint \
-H "Authorization: Bearer YOUR_TEST_JWT" \
-H "Content-Type: application/json" \
-d '{
"action": "status",
"redeemCode": "PP-SKI-N3B5F2-A7K9"
}'Deployment
# Deploy to production
supabase functions deploy fortem-mint --project-ref your-project-ref
# Set secrets (if not already set)
supabase secrets set FORTEM_API_URL=https://api.fortem.gg
supabase secrets set FORTEM_DEVELOPER_KEY=your_production_key
supabase secrets set FORTEM_COLLECTION_ID=your_production_collection
supabase secrets set GAME_BASE_URL=https://your-game.com
# Verify deployment
supabase functions list --project-ref your-project-ref
# Check logs
supabase functions logs fortem-mint --project-ref your-project-ref --followDeployment Checklist
- ForTem credentials switched to mainnet values
-
FORTEM_API_URLset tohttps://api.fortem.gg - CORS headers configured for your production domain
- Error logging and monitoring set up
- Test mint on mainnet with small amount first
What Comes Next
We now have secure, server-side minting infrastructure. But players cannot use it yet—they need a way to trigger minting from within the game.
In Article 4, we'll build the Multi-Step Minting UI in Phaser:
- Designing the mint button and item selection flow
- Connecting to the player's wallet
- Showing progress during minting (handling
PROCESSINGstatus) - Handling success, failure, and edge cases
- Making blockchain wait times feel acceptable
The goal is to make minting feel like a natural part of gameplay, not a confusing blockchain interaction.
Try It Yourself
Before the next article:
- Download the ForTem LLM prompt from docs.fortem.gg
- Set up Supabase Edge Functions locally with
supabase init - Create a test collection on ForTem's testnet
- Deploy the three-action Edge Function and test each action
The infrastructure you build now determines how smoothly everything else integrates.
See you in Article 4.
This is Part 3 of the "Building an NFT Marketplace for Your Game" series. Next up: Building the multi-step minting UI in Phaser.