Back to 0fee
0fee

Session 2: Dashboard, SDKs, Checkout Widget, and Celery in 60 Minutes

How we built the 0fee.dev dashboard, checkout widget, 2 SDKs, and Celery tasks in 60 minutes. Session 002 deep dive. By Juste A. Gnimavo and Claude.

Thales & Claude | March 25, 2026 11 min 0fee
session-002solidjsdashboardsdkscheckout-widgetcelery

Session 001 produced the backend. Session 002, still on December 10, 2025 -- the same day -- produced everything else a payment platform needs to be usable: two new payment providers, a complete SolidJS dashboard, an embeddable checkout widget, Celery background tasks, and official SDKs in TypeScript and Python. Sixty minutes.

What Session 002 Delivered

ComponentDescription
BUI ProviderFrancophone Africa -- CI, BJ, BF, CM, ML, SN, TG
PaiementPro ProviderFrancophone Africa + Cards -- CI, BJ, BF, GW, ML, NE, SN
SolidJS DashboardLogin, Dashboard, Apps, Transactions, Settings
Checkout.js WidgetEmbeddable payment widget with multi-step flow
Celery TasksWebhook retry, payment reconciliation, settlement
TypeScript SDKOfficial Node.js/TypeScript client
Python SDKOfficial Python client

By the end of Session 002, 0fee.dev had 7 payment providers, a frontend dashboard, a merchant-facing checkout widget, background job processing, and two SDKs ready for npm and PyPI.

Phase 1: Two New Providers

BUI Provider

BUI handles mobile money across Francophone West Africa. Its implementation required special handling for two distinct flows:

  • Orange Money (Ivory Coast): OTP-based validation. The customer receives an OTP on their phone, enters it in the checkout flow, and BUI validates it.
  • Wave: Redirect flow. BUI returns a payment URL, the customer is redirected to Wave's page, and 0fee.dev polls for completion.
pythonclass BuiProvider(BasePayinProvider):
    provider_id = "bui"
    provider_name = "BUI"
    supported_countries = ["CI", "BJ", "BF", "CM", "ML", "SN", "TG"]
    supported_methods = ["orange_money", "mtn", "wave", "moov"]

    async def initiate_payment(self, data: dict) -> InitPaymentResult:
        operator = data.get("payment_method_detail", "orange_money")

        payload = {
            "amount": data["amount"],
            "currency": data.get("currency", "XOF"),
            "phone": self._format_phone(
                data["customer"]["phone"],
                data.get("country", "CI")
            ),
            "operator": self._get_operator_code(operator),
            "reference": data.get("transaction_id", ""),
            "callback_url": data.get("callback_url", ""),
        }

        # Sign the request with HMAC
        signature = self._compute_hmac(payload)

        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.base_url}/payments",
                headers={
                    "Authorization": f"Bearer {self.api_key}",
                    "X-Signature": signature,
                },
                json=payload
            )

            result = response.json()

            if operator == "wave" and result.get("payment_url"):
                return InitPaymentResult(
                    provider_ref=result["id"],
                    status="redirect",
                    redirect_url=result["payment_url"],
                )
            else:
                return InitPaymentResult(
                    provider_ref=result["id"],
                    status="ussd_push",
                    instructions="Check your phone for the payment prompt.",
                )

PaiementPro Provider

PaiementPro is a major payment aggregator for Francophone Africa, supporting Orange Money, MTN, Moov, Wave, Free, Airtel, and even card payments. Its integration uses a redirect flow: the customer is sent to PaiementPro's hosted page where they complete the payment.

pythonclass PaiementProProvider(BasePayinProvider):
    provider_id = "paiementpro"
    provider_name = "PaiementPro"
    supported_countries = ["CI", "BJ", "BF", "GW", "ML", "NE", "SN"]
    supported_methods = [
        "orange_money", "mtn", "moov", "wave",
        "free", "airtel", "card"
    ]

    async def initiate_payment(self, data: dict) -> InitPaymentResult:
        channel = self._get_channel(data)

        payload = {
            "merchantId": self.merchant_id,
            "amount": data["amount"],
            "currency": data.get("currency", "XOF"),
            "channel": channel,
            "customerId": data.get("transaction_id", ""),
            "customerPhone": data["customer"]["phone"],
            "notificationURL": data.get("callback_url", ""),
            "returnURL": data.get("return_url", ""),
        }

        async with httpx.AsyncClient() as client:
            response = await client.post(
                f"{self.base_url}/init",
                headers={"Authorization": f"Bearer {self.api_key}"},
                json=payload
            )

            result = response.json()

            if result.get("success"):
                return InitPaymentResult(
                    provider_ref=result["transactionId"],
                    status="redirect",
                    redirect_url=result["paymentUrl"],
                )
            else:
                return InitPaymentResult(
                    provider_ref="",
                    status="failed",
                    raw_response=result,
                )

With BUI and PaiementPro, the provider count reached 7 -- comprehensive coverage for Francophone Africa with multiple fallback options per country.

Phase 2: The SolidJS Dashboard

The dashboard is the merchant-facing interface where developers manage their applications, API keys, provider credentials, and transactions. It was built with SolidJS, chosen for its fine-grained reactivity and minimal bundle size (7KB runtime).

Architecture

frontend/
├── package.json
├── vite.config.ts              # API proxy to backend
├── tailwind.config.js
├── index.html
└── src/
    ├── index.tsx                # Entry point
    ├── App.tsx                  # Router + layout
    ├── api/
    │   └── client.ts            # HTTP client with auth
    ├── stores/
    │   ├── auth.ts              # JWT + session management
    │   ├── apps.ts              # App CRUD + API keys
    │   └── transactions.ts      # Transaction listing
    ├── components/
    │   ├── Sidebar.tsx           # Navigation sidebar
    │   └── Header.tsx            # Top bar with user menu
    └── pages/
        ├── Login.tsx             # Email + password login
        ├── Dashboard.tsx         # Stats overview
        ├── Apps.tsx              # App management
        ├── Transactions.tsx      # Transaction list
        └── Settings.tsx          # Profile + webhooks

Key Pages

Dashboard.tsx -- The main overview page showing transaction volume, success rates, revenue, and recent activity. The stats are fetched from the backend and displayed with summary cards.

Apps.tsx -- Full CRUD for merchant applications. Each app has its own API keys, provider credentials, and routing configuration. The page supports creating new apps, viewing existing apps, generating API keys, and configuring webhook URLs.

tsx// SolidJS component for app API key management
function ApiKeySection(props: { appId: string }) {
    const [keys, setKeys] = createSignal<ApiKey[]>([]);
    const [loading, setLoading] = createSignal(false);

    const createKey = async () => {
        setLoading(true);
        try {
            const response = await apiClient.post(
                `/v1/apps/${props.appId}/keys`,
                { scopes: ["payments", "checkout"] }
            );
            // Show the full key ONCE -- it won't be shown again
            alert(`Your new API key: ${response.data.key}`);
            await fetchKeys();
        } finally {
            setLoading(false);
        }
    };

    return (
        <div class="space-y-4">
            <div class="flex justify-between items-center">
                <h3 class="text-lg font-semibold">API Keys</h3>
                <button
                    onClick={createKey}
                    disabled={loading()}
                    class="px-4 py-2 bg-blue-600 text-white rounded"
                >
                    Create Key
                </button>
            </div>
            <For each={keys()}>
                {(key) => (
                    <div class="flex justify-between p-3 bg-gray-50 rounded">
                        <span class="font-mono">{key.prefix}...****</span>
                        <span class="text-gray-500">{key.created_at}</span>
                    </div>
                )}
            </For>
        </div>
    );
}

Transactions.tsx -- A filterable, paginated list of all transactions across all apps. Filters include status (pending, completed, failed), provider, date range, and amount range.

Settings.tsx -- Profile management, security settings (password change, API key rotation), webhook configuration, and billing information.

Auth Store

Authentication uses JWT tokens stored in localStorage with automatic refresh:

typescript// stores/auth.ts
import { createSignal } from "solid-js";

const [token, setToken] = createSignal<string | null>(
    localStorage.getItem("zerofee_token")
);
const [user, setUser] = createSignal<User | null>(null);

export async function login(email: string, password: string) {
    const response = await fetch("/v1/auth/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
    });

    const data = await response.json();

    if (data.success) {
        localStorage.setItem("zerofee_token", data.data.token);
        setToken(data.data.token);
        setUser(data.data.user);
    }

    return data;
}

export function logout() {
    localStorage.removeItem("zerofee_token");
    setToken(null);
    setUser(null);
}

export { token, user };

Phase 3: The Checkout Widget

The checkout widget -- checkout.js -- is a JavaScript library that merchants embed on their websites. It opens a modal, presents payment methods based on the customer's country, collects payment details, and processes the payment. Think Stripe's stripe.js but for African mobile money.

Widget Initialization

html<!-- Merchant's website -->
<script src="https://cdn.0fee.dev/checkout.js"></script>
<script>
    const checkout = new ZeroFeeCheckout({
        publicKey: "pk_live_your_key_here",
        amount: 5000,
        currency: "XOF",
        onSuccess: function(result) {
            console.log("Payment completed:", result.transactionId);
            window.location.href = "/thank-you";
        },
        onError: function(error) {
            console.error("Payment failed:", error.message);
        },
        onClose: function() {
            console.log("Checkout closed");
        }
    });

    document.getElementById("pay-btn").addEventListener("click", () => {
        checkout.open();
    });
</script>

Multi-Step Flow

The widget implements a four-step payment flow:

  1. Country Selection. The customer selects their country from a grid. This determines which payment methods are available.
  1. Payment Method Selection. Based on the country, the widget displays available operators: Orange Money, MTN, Wave, Moov, Card, PayPal, etc. Each option shows the operator logo and name.
  1. Payment Details. For mobile money, the customer enters their phone number (pre-formatted with country code). For cards, they are redirected to the card provider's page.
  1. Processing and Confirmation. The widget shows a processing state, polls for status updates, and displays success or failure.

OTP Support

For Orange Money in Ivory Coast via BUI, the widget includes an OTP step:

typescript// In the checkout widget source
private async handleOtpRequired(transactionId: string) {
    this.showOtpInput();

    // Wait for user to enter OTP
    const otp = await this.waitForOtpSubmission();

    // Submit OTP to backend
    const response = await fetch(
        `${this.apiUrl}/v1/payments/${transactionId}/authenticate`,
        {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "Authorization": `Bearer ${this.publicKey}`,
            },
            body: JSON.stringify({ otp }),
        }
    );

    const result = await response.json();

    if (result.data.status === "completed") {
        this.showSuccess(result.data);
    } else {
        this.showError("OTP verification failed");
    }
}

Build Configuration

The widget is built as both an IIFE (for <script> tag inclusion) and an ES module (for bundler imports):

typescript// vite.config.ts
export default defineConfig({
    build: {
        lib: {
            entry: "src/checkout.ts",
            name: "ZeroFeeCheckout",
            formats: ["iife", "es"],
            fileName: (format) =>
                format === "iife" ? "checkout.js" : "checkout.esm.js",
        },
    },
});

Phase 4: Celery Background Tasks

Three categories of background tasks run on Celery with DragonflyDB as the broker:

Webhook Retry

When a webhook delivery fails (the merchant's endpoint returns a non-2xx status), the task queues a retry with exponential backoff:

python# tasks/webhook_retry.py
from celery import shared_task

RETRY_DELAYS = [60, 300, 1800, 7200, 28800, 86400]  # seconds
# 1min, 5min, 30min, 2h, 8h, 24h

@shared_task(bind=True, max_retries=6)
def deliver_webhook(self, webhook_id: str):
    """Deliver a webhook with exponential backoff retry."""
    webhook = get_webhook(webhook_id)

    if not webhook:
        return

    try:
        response = httpx.post(
            webhook["url"],
            json=webhook["payload"],
            headers={
                "Content-Type": "application/json",
                "X-ZeroFee-Signature": webhook["signature"],
                "X-ZeroFee-Event": webhook["event_type"],
            },
            timeout=30,
        )

        if response.status_code < 300:
            mark_webhook_delivered(webhook_id)
        else:
            raise Exception(
                f"Webhook returned {response.status_code}"
            )

    except Exception as exc:
        attempt = self.request.retries
        if attempt < len(RETRY_DELAYS):
            self.retry(
                exc=exc,
                countdown=RETRY_DELAYS[attempt]
            )
        else:
            mark_webhook_failed(webhook_id)

Payment Reconciliation

Every 5 minutes, a scheduled task checks for payments stuck in "pending" status and queries the provider for their current status:

python@shared_task
def reconcile_pending_payments():
    """Check status of pending payments older than 5 minutes."""
    pending = get_pending_payments(older_than_minutes=5)

    for payment in pending:
        provider = get_provider_for_payment(payment)
        status = provider.get_status(payment["provider_ref"])

        if status["status"] != "pending":
            update_payment_status(
                payment["id"],
                status["status"]
            )
            # Trigger webhook for status change
            deliver_webhook.delay(
                create_webhook_event(payment, status)
            )

Settlement Processing

A daily task at 00:30 UTC calculates settlement amounts for each merchant:

python@shared_task
def process_daily_settlement():
    """Calculate and record daily settlements for all apps."""
    apps = get_all_active_apps()

    for app in apps:
        completed = get_completed_payments_for_settlement(
            app["id"]
        )

        if not completed:
            continue

        total_amount = sum(p["amount"] for p in completed)
        total_fees = sum(p["fee_amount"] for p in completed)
        settlement_amount = total_amount - total_fees

        create_settlement_record(
            app_id=app["id"],
            amount=settlement_amount,
            fee=total_fees,
            transaction_count=len(completed),
        )

Celery Beat Schedule

python# tasks/celery_app.py
from celery import Celery
from celery.schedules import crontab

app = Celery("zerofee", broker="redis://localhost:6379/0")

app.conf.beat_schedule = {
    "process-webhook-queue": {
        "task": "tasks.webhook_retry.process_webhook_queue",
        "schedule": 60.0,  # Every minute
    },
    "reconcile-pending": {
        "task": "tasks.status_reconciliation.reconcile_pending_payments",
        "schedule": 300.0,  # Every 5 minutes
    },
    "daily-settlement": {
        "task": "tasks.settlement.process_daily_settlement",
        "schedule": crontab(hour=0, minute=30),  # 00:30 UTC
    },
}

Phase 5: TypeScript SDK

The official TypeScript SDK provides a type-safe client for the 0fee.dev API:

typescriptimport { ZeroFee } from "zerofee";

const zf = new ZeroFee("sk_live_your_key_here");

// Create a payment
const payment = await zf.payments.create({
    amount: 5000,
    payment_method: "PAYIN_ORANGE_CI",
    customer: {
        phone: "+2250709757296",
        name: "Amadou Diallo",
    },
});

console.log(payment.id);          // "txn_abc123..."
console.log(payment.status);      // "pending"
console.log(payment.provider);    // "paiementpro"

// Check payment status
const status = await zf.payments.get(payment.id);

// List payments with filters
const payments = await zf.payments.list({
    status: "completed",
    limit: 50,
});

// Create a checkout session
const session = await zf.checkout.sessions.create({
    amount: 10000,
    currency: "XOF",
    success_url: "https://example.com/success",
    cancel_url: "https://example.com/cancel",
});

Phase 6: Python SDK

The Python SDK follows the same patterns with Pydantic models for type validation:

pythonfrom zerofee import ZeroFee

zf = ZeroFee(api_key="sk_live_your_key_here")

# Create a payment
payment = zf.payments.create(
    amount=5000,
    payment_method="PAYIN_ORANGE_CI",
    customer={
        "phone": "+2250709757296",
        "name": "Amadou Diallo",
    },
)

print(payment.id)        # "txn_abc123..."
print(payment.status)    # "pending"
print(payment.provider)  # "paiementpro"

# Verify a webhook
from zerofee.webhooks import verify_signature

is_valid = verify_signature(
    payload=request.body,
    signature=request.headers["X-ZeroFee-Signature"],
    secret="whsec_your_webhook_secret",
)

The Session 002 Tally

MetricValue
New providers2 (BUI, PaiementPro)
Total providers7
Dashboard pages5 (Login, Dashboard, Apps, Transactions, Settings)
Checkout widget featuresCountry selection, method filtering, OTP, polling
Background task types3 (webhook retry, reconciliation, settlement)
SDKs2 (TypeScript, Python)
Time elapsed~60 minutes

Two sessions. Two hours total. The platform had a complete backend, 7 payment providers, a frontend dashboard, an embeddable checkout widget, background job processing, and official SDKs in two languages. Session 003 would add a marketing website, 5 more SDKs, and Docker configuration -- in a single sitting.


This article is part of the "How We Built 0fee.dev" series. 0fee.dev is a payment orchestrator covering 53+ providers across 200+ countries, built by Juste A. GNIMAVO and Claude from Abidjan with zero human engineers. Follow the series for the complete build story.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles