Back to projects
Digital Commerce & Gift Cards

Odigix

A digital gift card marketplace in Algeria. 30,000+ registered users buy PlayStation credits, Netflix subscriptions and mobile top-ups with local payment methods that actually work.

Visit site
Laravel APIMySQLRedisHorizonNext.jsInertia + ReactLaravel MCPEvolution API (WhatsApp)

The problem

Algeria has a young, tech-savvy population — median age around 29 — but almost no infrastructure for buying digital goods online. International gift cards (PlayStation, Netflix, Steam, Apple) aren't sold locally. The few resellers who exist operate through Instagram DMs and WhatsApp groups, taking manual payments and delivering codes via screenshot. It works, but it's slow, scam-prone, and doesn't scale.

The payment landscape makes it worse. Algerian bank and postal cards don't work on international platforms, so international gateways are out. Any platform that sells digital goods here has to integrate local payment providers — on Odigix, SlickPay and OneClick — and build its own layer around them.

Odigix needed to be the "Amazon for digital goods" in Algeria — a place where you search, click, pay with methods you already use, and get your code instantly.

What I built

I joined as the sole backend engineer and built the platform from the ground up — as a freelancer from 2023, then full-time since January 2026. The first storefront was built by another developer in Quasar (Vue.js); I designed the API contract and built every endpoint behind it.

Backend architecture

Client → Nginx → Laravel API → MySQL
                   ↓
              Redis (cache + queue)
                   ↓
            Horizon workers → Supplier APIs
                   ↓
            Payment webhooks ← Provider callbacks

Product catalog:

  • Products are organized by category (gaming, streaming, mobile, software) with support for regional variants (a PSN card for Algeria vs. one for France)
  • Pricing is pulled from supplier APIs and marked up by a configurable margin per category
  • Product listings are cached in Redis with a 5-minute TTL — the catalog changes rarely, so cache-first was the obvious choice
  • When a product goes out of stock at all suppliers, it's automatically hidden from the catalog

Order processing pipeline:

This is where the complexity lives. A single order touches four systems:

  1. Cart → Checkout: The API validates stock availability, calculates the final price (including any dynamic pricing rules), and creates an order in pending status
  2. Payment: The order is sent to the payment provider — SlickPay or OneClick. Each has its own flow, callbacks and failure modes; the API hides them behind one PaymentProvider interface
  3. Fulfillment: Once payment confirms (via webhook), the order moves to processing. A Horizon worker picks it up, calls the supplier API to purchase the code, and stores the redemption key encrypted in the database
  4. Delivery: The code is displayed on-screen, sent by email and by WhatsApp (through an Evolution API integration). If delivery fails (email bounces, user closes the browser), the code is held in escrow and can be retrieved from order history

All of this runs asynchronously through Laravel Horizon. The user sees a "processing" state that updates as each step completes. How fast a code arrives depends on the third-party supplier, so the system is built to be correct when a supplier is slow or down, not just fast when it's up.

Payment reconciliation:

SlickPay and OneClick confirm payments differently, on different timings. Neither the customer coming back from the payment page nor a single notification is enough on its own: the status is confirmed with the provider, and the same notification arriving twice must change nothing.

I built a unified ledger that normalizes all of this:

// Simplified — one normalizer per provider (SlickPay, OneClick)
class OrderLedger {
    public function recordPayment(string $orderId, string $provider, array $payload): void
    {
        $normalized = $this->normalize($provider, $payload);
        // Already recorded? Then this is a retry — do nothing.
        if (LedgerEntry::where('provider', $provider)->where('reference', $normalized->reference)->exists()) {
            return;
        }

        $order = $this->matchOrder($normalized); // by the reference we sent to the provider

        DB::transaction(function () use ($order, $normalized) {
            $order->update(['status' => 'paid', 'paid_at' => now()]);
            LedgerEntry::create([
                'order_id' => $order->id,
                'amount' => $normalized->amount,
                'currency' => $normalized->currency,
                'provider' => $normalized->provider,
                'reference' => $normalized->reference,
            ]);
        });
    }
}

Two rules carry most of the weight. Idempotency: every payment is keyed by provider and reference, so retried notifications and double clicks can't pay an order twice. No guessing: a payment that can't be matched to exactly one order is left for a human, never assigned to the closest candidate — gift cards come in fixed denominations, so identical amounts are common.

Supplier API reliability:

This was a constant headache. Some suppliers have 99.9% uptime; others go down multiple times a day. Early on, a single supplier outage would cascade — users would try to buy a product, the supplier would timeout, and the order would hang in processing indefinitely.

I implemented a circuit breaker:

  • If a supplier fails 3 times in 5 minutes, it's marked as circuit_open
  • While circuit is open, orders for that supplier's products are either rerouted to an alternative supplier or held in a queue
  • After 10 minutes, the circuit goes to half_open — one test order is sent through. If it succeeds, the circuit closes. If not, it stays open for another 10 minutes

This reduced user-facing failures by about 80%. The catalog stays up even when individual suppliers don't.

Database design

The schema is straightforward but has one interesting decision: redemption_codes are stored in a separate table with AES-256 encryption at the application level, not just at rest. The codes table has:

  • order_id (FK)
  • code_value (encrypted)
  • supplier_id (FK)
  • delivered_at (nullable — codes sit in escrow until the user retrieves them)
  • expires_at (some codes have expiry dates from the supplier)

The encryption key is loaded from environment variables and rotated quarterly. Overkill for most SaaS apps, but gift card codes are basically cash — if the database leaks, every undelivered code is stolen money.

The hard parts

Idempotent supplier purchases. A queued job that times out after the supplier has already charged Odigix must not buy the same code again on retry. Every purchase carries an idempotency key per order item, so a retry can't turn into a second purchase.

Rate limiting supplier APIs. Some suppliers have aggressive rate limits (10 requests/minute). When you have 30,000 users and flash sales, you burn through limits fast. I implemented a token bucket rate limiter per supplier, and when the bucket is empty, orders queue up instead of failing. During a Black Friday-like sale, the queue backed up by about 90 seconds — annoying but way better than errors.

Handling partial fulfillments. Sometimes an order has 3 items and only 2 succeed. The user paid for all 3. I built a partial fulfillment system where successful items are delivered immediately, and failed items are either retried automatically or refunded. The UI shows per-item status instead of a single order status.

The "where's my code?" problem. Users panic when they pay and don't get a code within 10 seconds. I added a real-time status page that shows each step of the pipeline (payment received → processing → code retrieved → delivered). Just showing progress reduced support tickets by about 40%.

Results

  • 30,000+ registered users within the first year
  • Automated delivery from multiple third-party suppliers, with per-supplier rate limiting, retries and circuit breakers
  • 99.5% uptime despite flaky supplier APIs
  • Zero data loss on payment callbacks (the ledger catches everything)
  • Support ticket volume dropped 40% after adding order status transparency

What's changing now

Odigix is being rebuilt around two goals: search traffic and faster operations.

  • Storefront moving to Next.js. The main reason is SEO: a server-rendered Next.js storefront gives every product and category a real, crawlable page, which a single-page app didn't.
  • Laravel Nova replaced by a custom admin. The admin area is moving from Laravel Nova to a custom, more capable admin built with Laravel, Inertia and React.
  • MCP in the admin. With Laravel MCP, admin operations are exposed as MCP tools, so an AI assistant can work with the same data and actions as the admin team.

Building something similar?

Tell me where you are and what's blocking you. I'll give you an honest read on how I'd approach it.