Bonaventure OgetoBy Bonaventure Ogeto|

Build a Gift Card System on Paystack

Build a gift card system by: 1) letting customers purchase gift cards via Paystack (any amount), 2) generating a unique redemption code after payment webhook fires, 3) emailing the code to the recipient, 4) allowing code redemption at checkout as a partial or full payment discount, and 5) tracking remaining balance per code.

Gift Card Purchase and Code Generation

Tables: gift_cards (code, initial_amount, remaining_balance, purchased_by, recipient_email, purchased_at, expires_at, status), gift_card_redemptions (gift_card_id, order_id, amount_used, redeemed_at).

var crypto = require('crypto');

function generateGiftCardCode() {
  // Format: XXXX-XXXX-XXXX-XXXX (16 uppercase letters/numbers)
  var bytes = crypto.randomBytes(8);
  var hex = bytes.toString('hex').toUpperCase();
  return hex.slice(0, 4) + '-' + hex.slice(4, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16);
}

async function purchaseGiftCard(buyerEmail, recipientEmail, amountInKobo) {
  var reference = 'GIFT-' + Date.now();
  var tempCode = generateGiftCardCode(); // hold before payment confirms

  // Initialize Paystack transaction
  var res = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      email: buyerEmail, amount: amountInKobo, currency: 'NGN', reference,
      metadata: { purpose: 'gift_card', recipient_email: recipientEmail, temp_code: tempCode },
    }),
  });
  return (await res.json()).data.authorization_url;
}

// Webhook: create gift card after payment
if (event.event === 'charge.success' && event.data.metadata.purpose === 'gift_card') {
  var meta = event.data.metadata;
  var expires = new Date();
  expires.setFullYear(expires.getFullYear() + 1); // 1-year expiry

  await db.giftCards.create({
    code: meta.temp_code,
    initial_amount: event.data.amount,
    remaining_balance: event.data.amount,
    purchased_by: event.data.customer.email,
    recipient_email: meta.recipient_email,
    expires_at: expires,
    status: 'active',
  });

  await sendGiftCardEmail(meta.recipient_email, meta.temp_code, event.data.amount);
}

Redemption at Checkout

async function applyGiftCard(cartTotal, giftCardCode) {
  var giftCard = await db.giftCards.findByCode(giftCardCode);

  if (!giftCard) throw new Error('Invalid gift card code');
  if (giftCard.status !== 'active') throw new Error('Gift card is not active');
  if (new Date() > giftCard.expires_at) throw new Error('Gift card has expired');
  if (giftCard.remaining_balance <= 0) throw new Error('Gift card has no remaining balance');

  var discount = Math.min(giftCard.remaining_balance, cartTotal);
  var remainingToPay = cartTotal - discount;

  return { discount, remainingToPay, giftCardBalance: giftCard.remaining_balance };
}

async function checkoutWithGiftCard(customerEmail, cartTotal, giftCardCode, orderId) {
  var { discount, remainingToPay } = await applyGiftCard(cartTotal, giftCardCode);

  // Record the redemption
  await db.giftCardRedemptions.create({ gift_card_code: giftCardCode, order_id: orderId, amount_used: discount });
  await db.giftCards.decrement(giftCardCode, 'remaining_balance', discount);
  if ((await db.giftCards.findByCode(giftCardCode)).remaining_balance === 0) {
    await db.giftCards.update(giftCardCode, { status: 'exhausted' });
  }

  if (remainingToPay <= 0) {
    // Fully covered by gift card — complete order without Paystack transaction
    await db.orders.update(orderId, { status: 'paid', payment_method: 'gift_card' });
    return { fullyPaid: true };
  }

  // Charge the remaining amount via Paystack
  var res = await fetch('https://api.paystack.co/transaction/initialize', {
    method: 'POST',
    headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: customerEmail, amount: remainingToPay, metadata: { order_id: orderId } }),
  });
  return { fullyPaid: false, authorizationUrl: (await res.json()).data.authorization_url };
}

Learn More

See build a loyalty and points system tied to payments for a similar value-credit system with a different accumulation model.

Sign up for the McTaba newsletter

Key Takeaways

  • Gift card codes should be randomly generated and cryptographically unpredictable — not sequential numbers.
  • Track balances per code, not per user — gift cards can be transferred or gifted to anyone.
  • Support partial redemption: a NGN 10,000 gift card can be used across multiple orders.
  • At checkout, apply the gift card discount server-side before initializing the Paystack transaction.
  • Gift cards are a liability until redeemed — account for them in your financial reconciliation.

Frequently Asked Questions

How do I handle gift cards in financial reconciliation?
Gift card purchases are a liability, not revenue — you have received money but not yet delivered value. When the gift card is redeemed, you convert the liability to revenue. Maintain a gift_card_liability ledger: credit on purchase, debit on redemption. Report unredeemed gift card balances as liabilities on your balance sheet.
Can I make gift cards available in different denominations?
Yes. Either let customers choose a preset amount (NGN 5,000, 10,000, 20,000) or enter a custom amount within a range you define (minimum NGN 1,000, maximum NGN 100,000). Custom amounts make gift cards more flexible; preset amounts simplify your product catalog.
What if someone enters a wrong gift card code at checkout?
Return a clear error: "Invalid gift card code. Please check the code and try again." After 3 failed attempts with different codes from the same IP or session, add a brief delay or CAPTCHA to prevent brute-force guessing. Log failed attempts for security monitoring.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon