Supabase Edge Functions for Server-side NFT Minting

deps

deps

Supabase Edge Functions for Server-side NFT Minting

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:

  1. Visit docs.fortem.gg
  2. Look for the "Prompt (For LLMs)" download button
  3. 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:

ActionPurposeWhen Called
mintCreate a new NFT on ForTem/SuiPlayer clicks "Mint Item"
statusCheck if minting is completePolling during PROCESSING
redeemVerify NFT has been burned for in-game benefitsPlayer 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
VariableDescriptionExample Value
FORTEM_API_URLForTem API endpointhttps://testnet-api.fortem.gg
FORTEM_DEVELOPER_KEYYour ForTem developer API keydev_xxxxx
FORTEM_COLLECTION_IDYour NFT collection ID12345
GAME_BASE_URLBase URL for resolving relative image pathshttps://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 logic

Note: We're using SUPABASE_ANON_KEY with 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

StatusDescriptionClient Action
PROCESSINGNFT is being minted on-chainShow spinner, poll again
MINTEDNFT successfully createdShow success, enable trading
OFFER_PENDINGTrade offer in progressItem temporarily locked
KIOSK_LISTEDListed on marketplaceAvailable for purchase
REDEEMEDBurned for in-game benefitsGrant 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 --follow

Deployment Checklist

  • ForTem credentials switched to mainnet values
  • FORTEM_API_URL set to https://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 PROCESSING status)
  • 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:

  1. Download the ForTem LLM prompt from docs.fortem.gg
  2. Set up Supabase Edge Functions locally with supabase init
  3. Create a test collection on ForTem's testnet
  4. 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.