Skip to main content

Issuing Cards

Learn how to issue virtual and physical cards for your customers using Zentra’s card issuing API. Enable spending, set limits, and manage card lifecycle.
This guide now follows the reviewed public gateway contract. Older SDK examples and older internal docs may still refer to freeze, unfreeze, terminate, or updateLimits. In the reviewed public HTTP contract, those controls map to lock, unlock, and security settings instead.

What You’ll Learn

  • Issue virtual cards instantly
  • Request physical cards
  • Fund and manage cards
  • Set spending limits
  • Handle card transactions
  • Lock, unlock, and secure cards

Prerequisites

  • Zentra API account with card issuing enabled
  • Customer KYC verified
  • Sufficient wallet balance for funding

Card Issuing Flow

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   Customer   │────▶│  Your Server │────▶│  Zentra API  │
│   Request    │     │   (Create)   │     │   (Issue)    │
└──────────────┘     └──────────────┘     └──────────────┘
       │                    │                    │
       │   1. Request       │   2. Create        │
       │      Card          │      Card          │
       │                    │   ──────────────▶  │
       │                    │                    │   3. Provision
       │                    │   ◀──────────────  │      with Network
       │   4. Return        │                    │
       │      Card Details  │                    │
       │   ◀────────────────│                    │

Step 1: Create a Virtual Card

Issue a virtual card instantly:
const Zentra = require('@zentra/sdk');

const client = new Zentra.Client({
  apiKey: process.env.ZENTRA_SECRET_KEY
});

async function issueVirtualCard(customerId, options) {
  // Create the card
  const card = await client.cards.create({
    customer_id: customerId,
    type: 'virtual',
    currency: options.currency || 'USD',
    single_use: Boolean(options.singleUse),
    geofence_enabled: Boolean(options.geofenceEnabled),
    geofence_max_distance_km: options.geofenceMaxDistanceKm || 250
  });

  return {
    id: card.id,
    last4: card.last4,
    brand: card.brand,
    expiry: `${card.expiry_month}/${card.expiry_year}`,
    status: card.status,
    // Sensitive details - only show once
    pan: card.pan,
    cvv: card.cvv
  };
}
Sensitive card details are card-program specific. Do not assume PAN/CVV retrieval is part of the default reviewed public gateway unless your approved card program explicitly exposes it.

Disposable / Single-Use Virtual Cards

Use single_use: true to issue a virtual card that auto-terminates after its first successful authorization:
await client.cards.create({
  customer_id: customerId,
  type: 'virtual',
  currency: 'NGN',
  single_use: true
});
This is opt-in per card. Cards without single_use: true remain standard multi-use cards.

Location-Aware Geofencing (Optional)

Geofencing is optional and card-scoped (geofence_enabled: true). When enabled, authorization can auto-decline physical-channel transactions if merchant location and device location are too far apart.

Step 2: Display Card Details Securely

Use the Zentra UI SDK for PCI-compliant card display:
import { CardReveal } from '@zentra/ui-react';

function CardDetailsView({ cardId, accessToken }) {
  return (
    <CardReveal
      cardId={cardId}
      accessToken={accessToken}
      onReveal={() => console.log('Card details revealed')}
      theme={{
        backgroundColor: '#1a1a2e',
        textColor: '#ffffff',
        accentColor: '#5770A0'
      }}
    />
  );
}

Step 3: Fund a Card

Add funds to an existing card:
async function fundCard(cardId, amountMinor, source) {
  // Verify the funding balance in your own app or ledger projection
  const wallet = await db.wallets.getOrCreate(source.userId);
  
  if (wallet.balance_minor < amountMinor) {
    throw new Error('Insufficient wallet balance');
  }

  // Fund the card
  const funding = await client.cards.fund({
    card_id: cardId,
    amountMinor,
    source: 'wallet',
    transaction_reference: `card_fund_${cardId}_${Date.now()}`,
    idempotency_key: `FUND_${cardId}_${Date.now()}`
  });

  return funding;
}
Use a stable transaction_reference for client retries and reconciliation; keep it unique per logical funding action. Legacy reference is still accepted for backward compatibility, but it is deprecated for card funding and should be migrated.

Step 4: Update Card Security Controls

Use card security settings to update spending limits and related controls:
async function updateCardSecurity(cardId, limits) {
  const response = await fetch(`https://api.usezentra.com/api/v1/cards/${cardId}/security`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${process.env.ZENTRA_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
    spending_limits: {
      daily: limits.daily,           // Per-day limit
      monthly: limits.monthly,       // Per-month limit
      per_transaction: limits.perTx, // Single transaction max
    },
    merchant_controls: {
      allowed_categories: limits.allowedCategories || [],
      blocked_categories: limits.blockedCategories || [],
      allowed_countries: limits.allowedCountries || ['US', 'GB', 'NG']
    }
    })
  });

  return response.json();
}

// Example: Restrict to subscriptions only
await updateCardSecurity(cardId, {
  daily: 50000,      // $500
  monthly: 200000,   // $2,000
  perTx: 10000,      // $100 per transaction
  allowedCategories: ['subscription_services', 'digital_goods'],
  blockedCategories: ['gambling', 'adult_content']
});

Step 5: Handle Card Transactions

Process transaction webhooks:
async function handleCardTransaction(data) {
  const { card_id, amount_minor, merchant, status, type } = data;

  // Find the card owner
  const card = await db.cards.findUnique({
    where: { zentra_id: card_id },
    include: { user: true }
  });

  // Log the transaction
  await db.cardTransactions.create({
    data: {
      card_id: card.id,
      amount_minor,
      merchant_name: merchant.name,
      merchant_category: merchant.category,
      status,
      type, // 'authorization', 'capture', 'refund'
      created_at: new Date()
    }
  });

  // Send notification
  if (status === 'approved') {
    await pushService.send(card.user_id, {
      title: 'Card Transaction',
      body: `${(amount_minor / 100).toFixed(2)} ${card.currency} at ${merchant.name}`
    });
  }
}

Step 6: Card Controls

Lock a Card

Temporarily disable a card:
async function lockCard(cardId, reason) {
  const response = await fetch(`https://api.usezentra.com/api/v1/cards/${cardId}/lock`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.ZENTRA_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ reason, request_id: `lock_${cardId}_${Date.now()}` })
  });
  const card = await response.json();

  await db.cards.update({
    where: { zentra_id: cardId },
    data: { status: 'frozen', frozen_at: new Date() }
  });

  return card;
}

Unlock a Card

Re-enable a locked card:
async function unlockCard(cardId) {
  const response = await fetch(`https://api.usezentra.com/api/v1/cards/${cardId}/unlock`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.ZENTRA_SECRET_KEY}`,
    }
  });
  const card = await response.json();

  await db.cards.update({
    where: { zentra_id: cardId },
    data: { status: 'active', frozen_at: null }
  });

  return card;
}
The reviewed public gateway does not currently expose a dedicated card termination endpoint. Use lock plus security controls for public contract flows, and coordinate permanent closure through your approved operational path if your card program requires it.

Card Types & Features

FeatureVirtualPhysical
Instant IssuanceYesNo (3-5 days)
Online PaymentsYesYes
In-Store (POS)NoYes
ATM WithdrawalNoYes
Apple/Google PayYesYes
ReplacementInstant3-5 days

Supported Networks

NetworkCurrenciesRegions
VisaUSD, EUR, GBP, NGNGlobal
MastercardUSD, EUR, GBPGlobal
VerveNGNNigeria

Error Handling

try {
  const card = await client.cards.create(cardData);
  return card;
} catch (error) {
  switch (error.code) {
    case 'insufficient_funds':
      throw new Error('Not enough balance to fund card');
    case 'kyc_required':
      throw new Error('Customer KYC verification required');
    case 'card_limit_reached':
      throw new Error('Maximum cards per customer reached');
    case 'invalid_currency':
      throw new Error('Currency not supported for card issuing');
    default:
      throw new Error('Card creation failed');
  }
}

Next Steps

Cards API

Full API reference

UI SDK

Secure card display

Webhooks

Handle card events

Going Live

Production checklist