Back to deblo
deblo

Credits, FCFA, and 6 African Payment Gateways

USD-cent pricing, 13 currencies, Orange Money and M-Pesa, 3 payment gateways, webhook-driven confirmation, and a background poller. Monetizing an African SaaS.

Thales & Claude | March 25, 2026 16 min deblo
deblopaymentscreditsfcfaorange-moneystripeafrica

By Thales & Claude -- CEO & AI CTO, ZeroSuite, Inc.

When you build a SaaS product for the United States or Europe, payments are a solved problem. You integrate Stripe, configure a few webhook endpoints, and move on to the next feature. When you build a SaaS product for West and Central Africa, payments are the feature. They are the hardest engineering problem on your plate, and they remain fragile long after launch.

The reason is simple: the payment infrastructure in Sub-Saharan Africa is fragmented across dozens of mobile money providers, each operating in a single country, each with its own API, its own webhook format, its own settlement timeline, and its own failure modes. There is no Stripe equivalent that just works everywhere. You either integrate multiple gateways or you do not collect money.

This article explains how we built the credit system and payment infrastructure for Deblo.ai -- from the pricing model to the currency conversion to the six payment gateway integrations that allow a student in Abidjan to recharge credits with Orange Money and a professional in Nairobi to pay with M-Pesa.

---

The Credit System: Universal Currency for an AI Platform

The first decision was the pricing model. We considered several options:

  • Monthly subscription: Too expensive for most African students. A fixed monthly fee assumes predictable usage, which does not match how students interact with an AI tutor (heavy use before exams, almost zero during holidays).
  • Per-message pricing: Too unpredictable. Users would be afraid to ask follow-up questions.
  • Credits: The right balance. Users buy credits in advance, spend them as they go, and get free daily refills to ensure even non-paying users can learn.

Credits are the universal currency inside Deblo. Every interaction costs credits, but the cost varies by complexity:

  • K12 (student) mode: 1 credit per text question, 2 credits per photo analysis.
  • Pro (professional) mode: Token-based pricing -- 1 credit per 3,000 tokens for standard queries, 1 credit per 2,000 tokens for complex reasoning tasks.
  • Voice calls: 5 credits per minute.

---

USD-Cent Pricing With Local Currency Display

All prices in Deblo are stored internally in USD cents. This is a deliberate decision that avoids the nightmare of maintaining per-country price lists.

The alternative -- pricing directly in FCFA, Naira, Cedi, Shilling, and Franc -- would mean recalculating prices every time an exchange rate moves, maintaining ten different price tables, and dealing with rounding discrepancies across currencies. By pricing in USD cents and converting at display time, we have a single source of truth for pricing and the user always sees prices in their local currency.

The conversion uses a free exchange rate API (fawazahmed0) with a 6-hour localStorage cache to minimize API calls:

// Frontend currency conversion (conceptual pattern)
const CACHE_KEY = 'deblo_rates';
const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours

const SUPPORTED_CURRENCIES = [ 'XOF', 'XAF', 'NGN', 'GHS', 'KES', 'CDF', 'ZAR', 'USD', 'EUR', 'MAD', 'TND', 'EGP', 'GBP', ];

async function getRate(currency: string): Promise { const cached = localStorage.getItem(CACHE_KEY); if (cached) { const { rates, timestamp } = JSON.parse(cached); if (Date.now() - timestamp < CACHE_TTL && rates[currency]) { return rates[currency]; } }

const response = await fetch( 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json' ); const data = await response.json(); const rates = data.usd;

localStorage.setItem(CACHE_KEY, JSON.stringify({ rates, timestamp: Date.now(), }));

return rates[currency.toLowerCase()] ?? 1; }

function formatPrice(usdCents: number, currency: string, rate: number): string { const localAmount = (usdCents / 100) * rate; return new Intl.NumberFormat('fr-FR', { style: 'currency', currency, maximumFractionDigits: 0, }).format(Math.ceil(localAmount)); } ```

The user sees "500 FCFA" or "2,000 NGN" in the UI, but the backend only knows "100 USD cents." This decoupling means we can adjust global pricing once and it propagates to all currencies automatically.

---

The CreditLedger: Single Source of Truth

Early in development, we tracked credit movements across multiple tables -- credit_purchases for top-ups, credit_usages for consumption. This quickly became a debugging nightmare. When a user's balance seemed wrong, we had to query two tables and manually reconcile.

The solution was the CreditLedger -- a single append-only table that records every credit movement, in any direction, for any reason:

# backend/app/models/credit.py

class CreditLedger(Base): """Single source of truth for ALL credit movements.""" __tablename__ = "credit_ledger"

id = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) user_id = Column( UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True, ) direction = Column(String(6), nullable=False) # 'credit' | 'debit' amount = Column(Integer, nullable=False) # always positive balance_after = Column(Integer, nullable=True) # snapshot after movement event = Column(String(30), nullable=False) # event type description = Column(String(255), nullable=False) reference_id = Column(UUID(as_uuid=True), nullable=True) conversation_id = Column( UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=True, ) created_at = Column( DateTime(timezone=True), server_default=func.now(), index=True, )

__table_args__ = ( Index("ix_credit_ledger_event", "event"), ) ```

Every credit movement -- signup bonus, recharge, coupon redemption, AI usage, admin adjustment, referral bonus, voice call charge -- goes through a single function that writes to this ledger:

# backend/app/services/credits.py

async def log_credit_event( user: User, direction: str, amount: int, event: str, description: str, db: AsyncSession, reference_id: UUID | None = None, conversation_id: UUID | None = None, ) -> None: """Log a credit movement to the ledger.""" entry = CreditLedger( user_id=user.id, direction=direction, amount=abs(amount), balance_after=user.credit_balance + user.free_credits_today, event=event, description=description, reference_id=reference_id, conversation_id=conversation_id, ) db.add(entry) ```

The balance_after field is critical. It creates a running balance that can be audited independently of the user.credit_balance field. If the two ever diverge, we know something went wrong and can trace exactly where.

The event types are standardized:

EventDirectionDescription
signup_bonuscredit200 credits on account creation
purchasecreditRecharge via payment gateway
couponcreditPromotional code redemption
ai_bonuscredit1-5 bonus credits per interaction
admin_grantcreditManual adjustment by admin
usage_textdebitText question (1 credit)
usage_photodebitPhoto analysis (2 credits)
usage_prodebitPro token-based usage
usage_voicedebitVoice call (5 credits/min)
admin_deductdebitManual adjustment by admin

---

Free Credits: The Generosity Engine

We give away a lot of credits for free. This is intentional. Deblo is an educational platform targeting students in some of the world's poorest countries. Gating knowledge behind a paywall -- even a small one -- contradicts the mission.

The free credit system has four components:

1. Signup bonus: 200 credits. Every new user starts with 200 credits, enough for roughly 200 text questions or 100 photo analyses. This is enough to use the platform meaningfully for a week or more.

2. Daily refill: 30 credits. Every day at midnight (WAT -- West Africa Time), each user's free credits reset to 30. This ensures that even users who never pay can ask 30 questions per day, indefinitely.

3. AI bonus: 1-5 credits per interaction. After each exchange, the AI can award bonus credits to encourage studious behavior. A student who shows their work and asks thoughtful follow-up questions gets more bonus credits than one who just asks for answers.

4. Referral bonus: 150 credits. When a user refers a friend who signs up and makes their first recharge, both the referrer and the referee receive 150 credits.

The daily refill is implemented as a lazy check -- we do not run a cron job at midnight. Instead, we check on every balance query whether the user's free_credits_date is today. If it is not, we reset their free credits:

# backend/app/services/credits.py

async def _refill_daily_free(user: User, db: AsyncSession) -> None: """Reset free daily credits if it's a new day.""" today = date.today() if user.free_credits_date != today: user.free_credits_today = settings.FREE_DAILY_CREDITS user.free_credits_date = today db.add(user) ```

This lazy approach means we never have a midnight spike of database updates. The "refill" happens organically when each user first interacts with the platform each day.

---

10 Recharge Packages

Recharge packages are priced in FCFA (Franc CFA, the common currency in the WAEMU and CEMAC zones) and provide credits with bonus credits at higher tiers:

# backend/app/services/credits.py

RECHARGE_OPTIONS = [ {"id": "r10", "credits": 20, "bonus": 0, "price_fcfa": 100}, {"id": "r50", "credits": 100, "bonus": 0, "price_fcfa": 500}, {"id": "r100", "credits": 200, "bonus": 10, "price_fcfa": 1000}, {"id": "r500", "credits": 1000, "bonus": 50, "price_fcfa": 5000}, {"id": "r1k", "credits": 2000, "bonus": 160, "price_fcfa": 10000}, {"id": "r5k", "credits": 10000, "bonus": 1000, "price_fcfa": 50000}, {"id": "r10k", "credits": 20000, "bonus": 2400, "price_fcfa": 100000}, {"id": "r20k", "credits": 40000, "bonus": 6000, "price_fcfa": 200000}, {"id": "r50k", "credits": 100000, "bonus": 20000, "price_fcfa": 500000}, {"id": "r100k", "credits": 200000, "bonus": 50000, "price_fcfa": 1000000}, ] ```

The range is intentionally wide. At the bottom, 100 FCFA (about $0.16 USD) buys 20 credits -- accessible to nearly any student. At the top, 1,000,000 FCFA (about $1,600 USD) buys 250,000 credits and is targeted at professional firms using Deblo Pro across an entire team.

The bonus credits at higher tiers create a natural incentive to buy larger packages. The effective price per credit drops from 5 FCFA at the lowest tier to 4 FCFA at the highest, a 20% discount for volume.

---

Three Payment Gateways, Ten Countries

Collecting money in Africa requires multiple payment gateways because no single provider covers all countries and all payment methods. We integrate three gateways:

1. Zerofee (0fee.dev)

Our primary gateway for mobile money in Francophone West Africa. Zerofee is an aggregator that connects to Orange Money, Wave, MTN MoMo, and other mobile money providers across multiple countries. The integration is a standard checkout-session flow:

# backend/app/services/zerofee.py

async def create_checkout( amount_fcfa: int, reference: str, success_url: str, cancel_url: str, ) -> dict: """Create a 0fee checkout session.""" async with httpx.AsyncClient() as client: res = await client.post( settings.ZEROFEE_CHECKOUT_URL, headers={ "Authorization": f"Bearer {settings.ZEROFEE_API_KEY}" }, json={ "amount": amount_fcfa, "source_currency": "XOF", "payment_reference": reference, "success_url": success_url, "cancel_url": cancel_url, }, ) res.raise_for_status() payment_data = res.json()["data"] return { "checkout_url": payment_data["checkout_url"], "payment_id": payment_data["id"], } ```

2. Stripe

For international payments (credit/debit cards, Apple Pay, Google Pay) and users in countries where mobile money is not the dominant payment method. Stripe also serves as the payment method for diaspora users who have Western bank accounts.

3. PaiementPro / XPaye

A Francophone African payment gateway that supports direct mobile money integration via a JavaScript SDK. PaiementPro covers 10 countries with 16 payment channels:

# backend/app/services/xpaye.py

XPAYE_CHANNELS: dict[str, str] = { # C\u00f4te d'Ivoire "orange_ci": "OMCIV2", "mtn_ci": "MOMOCI", "moov_ci": "FLOOZ", "wave_ci": "WAVECI", # B\u00e9nin "mtn_bj": "MOMOBJ", "moov_bj": "FLOOZBJ", # Burkina Faso "orange_bf": "OMBF", # Cameroun "orange_cm": "OMCM", "mtn_cm": "MOMOCM", # Guin\u00e9e-Bissau "orange_gw": "OMGN", # Mali "orange_ml": "OMML", # Niger "airtel_ne": "AIRTELNG", # S\u00e9n\u00e9gal "orange_sn": "OMSN", "free_sn": "FREE", "emoney_sn": "EMONEY", "wave_sn": "WAVESN", # Togo "moov_tg": "MOOVTG", "togocel_tg": "TOGOCEL", } ```

The frontend selects the available payment methods based on the user's detected country:

# backend/app/services/xpaye.py

XPAYE_COUNTRY_METHODS: dict[str, list[str]] = { "CI": ["orange_ci", "mtn_ci", "moov_ci", "wave_ci"], "BJ": ["mtn_bj", "moov_bj"], "BF": ["orange_bf"], "CM": ["orange_cm", "mtn_cm"], "GW": ["orange_gw"], "ML": ["orange_ml"], "NE": ["airtel_ne"], "SN": ["orange_sn", "free_sn", "emoney_sn", "wave_sn"], "TG": ["moov_tg", "togocel_tg"], } ```

When a user in Cote d'Ivoire opens the recharge page, they see Orange Money, MTN MoMo, Moov Flooz, and Wave. A user in Senegal sees Orange Money, Free Money, E-Money, and Wave. A user in a country not covered by XPaye sees the Zerofee checkout or Stripe as fallback.

---

Webhook-Driven Confirmation

Each gateway notifies us of payment outcomes via webhooks. The webhook handlers all follow the same pattern: verify the signature, find the CreditPurchase record by payment reference, credit the user if the payment succeeded, and log to the ledger:

# backend/app/routes/webhooks.py

@router.post("/zerofee") async def zerofee_webhook( request: Request, db: AsyncSession = Depends(get_db), ): """Handle 0fee payment webhooks (no JWT auth required).""" payload = await request.body() signature = request.headers.get("X-ZeroFee-Signature", "")

if not verify_webhook(payload, signature): raise HTTPException(status_code=401, detail="Invalid signature")

data = await request.json() event_type = data.get("type", "") payment_ref = data.get("payment_reference", "")

if event_type in ("payment.completed", "checkout.session.completed"): # Find purchase, credit user, log to ledger purchase = await _find_purchase(payment_ref, db) if purchase and purchase.status != "completed": purchase.status = "completed" user = await _find_user(purchase.user_id, db) if user: user.credit_balance += purchase.amount await log_credit_event( user, "credit", purchase.amount, "purchase", f"Recharge \u2014 {purchase.amount} cr\u00e9dits", db, reference_id=purchase.id, ) await db.commit() return {"status": "credits_added"} ```

The Zerofee webhook uses HMAC-SHA256 signature verification with a 5-minute replay window:

# backend/app/services/zerofee.py

def verify_webhook(payload: bytes, signature: str) -> bool: """Verify 0fee webhook HMAC-SHA256 signature.

Header: X-ZeroFee-Signature Format: t=TIMESTAMP,v1=HEX_SIGNATURE Signed payload: {timestamp}.{raw_body} Tolerance: 5 minutes. """ parts = dict(p.split("=", 1) for p in signature.split(",")) timestamp = parts.get("t", "") received_sig = parts.get("v1", "")

# Reject replayed webhooks older than 5 minutes age = int(time.time()) - int(timestamp) if age > 300: return False

expected = hmac.new( settings.ZEROFEE_WEBHOOK_SECRET.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256, ).hexdigest()

return hmac.compare_digest(expected, received_sig) ```

---

The Payment Poller: Because Webhooks Fail

Here is the uncomfortable truth about webhooks in Africa: they fail. Regularly. The reasons are manifold -- the payment gateway's server has a momentary outage, our server was restarting during a deployment, the network between the two dropped the request, or the webhook was sent but our infrastructure did not process it before the TCP timeout.

Relying solely on webhooks for payment confirmation means some users will pay money and never receive their credits. This is unacceptable.

Our solution is the payment poller -- a background task that runs every 30 seconds and checks all pending payments against the gateway APIs. If a payment is confirmed by the API but was missed by the webhook, the poller credits the user:

# backend/app/services/payment_poller.py

# Check schedule: aggressive early, tapering off _CHECK_SCHEDULE = [ 60, # 1 min 180, # 3 min 300, # 5 min 600, # 10 min 1800, # 30 min 3600, # 1 hour 7200, # 2 hours 14400, # 4 hours 28800, # 8 hours 57600, # 16 hours ]

MAX_AGE_SECONDS = 86400 # 24 hours -- stop checking after this

async def _poller_loop(): """Main polling loop -- runs every 30 seconds.""" while True: await asyncio.sleep(30) stats = await _reconcile_once() if stats.get("checked", 0) > 0: logger.info(f"Poller sweep: {stats}") ```

The check schedule is deliberately front-loaded: the first three checks happen within 5 minutes of payment creation (when the user is most likely still staring at their screen waiting), then tapers off to hourly checks, and stops entirely after 24 hours (at which point the payment is marked as expired).

The poller dispatches to the correct gateway API based on the payment_method stored on the CreditPurchase record -- XPaye payments are verified via the PaiementPro API, Stripe payments via the Stripe Checkout Session API, and Zerofee payments via the Zerofee session status endpoint.

This dual-confirmation approach (webhooks plus polling) has saved dozens of payments that would otherwise have been lost. In our first month of operation, roughly 8% of successful payments were credited by the poller rather than the webhook. That number has improved as we stabilized our infrastructure, but the poller remains essential insurance.

---

Credit Deduction: Priority Ordering

When a user spends credits, the deduction follows a priority order: recharged credits first, free daily credits second. This is a business decision -- we want users to feel that their paid credits are being consumed (creating motivation to recharge), while their free credits serve as a safety net.

The implementation handles partial deductions across both pools:

# backend/app/services/credits.py

async def deduct_credits( user: User, cost: int, usage_type: str, conversation_id: UUID | None, db: AsyncSession, ) -> str: """Deduct credits using priority: recharge -> free.""" await _refill_daily_free(user, db) remaining = cost source = "free"

# 1. Try recharge balance first if remaining > 0 and user.credit_balance > 0: deduct = min(remaining, user.credit_balance) user.credit_balance -= deduct remaining -= deduct source = "recharge"

# 2. Try free daily credits if remaining > 0 and user.free_credits_today > 0: deduct = min(remaining, user.free_credits_today) user.free_credits_today -= deduct remaining -= deduct source = "free"

# Record usage and log to ledger usage = CreditUsage( user_id=user.id, conversation_id=conversation_id, credits_used=cost, usage_type=usage_type, source=source, ) db.add(usage)

event_map = { "text": "usage_text", "photo": "usage_photo", "pro_tokens": "usage_pro", "voice": "usage_voice", } event = event_map.get(usage_type, "usage_text") await log_credit_event( user, "debit", cost, event, EVENT_DESCRIPTIONS.get(event, "Utilisation"), db, reference_id=usage.id, conversation_id=conversation_id, ) return source ```

---

What We Learned About Payments in Africa

1. Webhooks are necessary but not sufficient. Always have a polling fallback. Network reliability in Africa is not where it needs to be, and your users should never suffer for it.

2. Mobile money is not one thing. Orange Money in Cote d'Ivoire and Orange Money in Senegal are different systems with different APIs, different settlement times, and different failure modes. Treating them as interchangeable will cause bugs.

3. Price in the smallest unit. Storing prices in FCFA (which has no centimes) or USD cents (integers, no floating point) eliminates an entire class of rounding bugs.

4. Give credits generously. For an educational platform, the marginal cost of a free credit is near zero (it is an API call to an LLM), but the value to the user is immense. Our generous free tier is not charity -- it is user acquisition strategy.

5. The ledger is sacred. Once we moved to a single CreditLedger table for all credit movements, debugging payment issues went from "check five tables and pray" to "query one table and see the full history."

Building payment infrastructure for Africa is not glamorous work. It is plumbing -- essential, invisible when it works, catastrophic when it breaks. But getting it right means a student in Dakar can buy 20 credits for 100 FCFA with Wave and start learning immediately. That is worth the complexity.

---

This is article 6 of 12 in the "How We Built Deblo.ai" series.

1. The Architecture of an African AI Tutor 2. Prompt Engineering for 15 School Subjects 3. Photo Analysis: From Homework to AI 4. Building Deblo Pro: 101 AI Advisors for African Professionals 5. WhatsApp OTP and the African Authentication Problem 6. Credits, FCFA, and 6 African Payment Gateways (you are here) 7. SSE Streaming: Real-Time AI Responses in SvelteKit 8. Voice Calls With AI: Ultravox, LiveKit, and WebRTC 9. The Curriculum Engine: CEPE, BEPC, and BAC Prep 10. Gamification: XP, Streaks, and Bonus Credits 11. Going Mobile: React Native and Expo 12. From Abidjan to Production: Deploying Deblo

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles