DEV Community

Cover image for From Idea to Production: How I Built a Decoupled Chatbot Ordering Engine
Dillibe Chisom Okorie
Dillibe Chisom Okorie

Posted on

From Idea to Production: How I Built a Decoupled Chatbot Ordering Engine

Micro-merchants on Instagram and WhatsApp lose sales every day because they can't reply fast enough and they can't afford a full e-commerce setup.

So I built Byte-to-Bite: a conversational food-ordering engine where customers browse a menu, build a cart, and pay securely — all inside a chat window. No login wall. No heavy frontend. Just architecture.

Here's the full breakdown using the STAR pattern.


Situation: The problem with micro-commerce

Small vendors operating on Instagram and WhatsApp are doing real business but they're bottlenecked by manual replies. A customer DMs asking for a menu, waits, builds an order in a back-and-forth thread, then sends payment to a personal account.

The goal was a headless, automated agent. Customers get a live menu, a real cart, a custom invoice, and a secure checkout, without ever touching a login form.


Task: Three hard architecture problems

  1. Stateless HTTP forgets you. The backend had to act as a robust Finite State Machine (FSM) to track exactly where each user was in the checkout pipeline across requests.
  2. Cross-layer identity. Data had to flow cleanly between Next.js, an Express API, and Paystack without dropping the tracking thread.
  3. Concurrent isolation. Multiple shoppers had to build independent carts simultaneously with zero data leakage between sessions.

Action: How the three layers talk to each other

1. The Frontend Passport

On first load, the browser generates a UUID via the Web Crypto API:

const deviceId = window.crypto.randomUUID();
localStorage.setItem('x-device-id', deviceId);
Enter fullscreen mode Exit fullscreen mode

Every outgoing Axios request injects this as a custom header:

axios.defaults.headers.common['x-device-id'] = localStorage.getItem('x-device-id');
Enter fullscreen mode Exit fullscreen mode

No cookies. No registration. The client is its own passport.

2. The Headless Brain (FSM in TypeScript)

The backend reads the incoming device ID, pulls the user's MongoDB session, and routes the request through an FSM using strict union types:

type UserState = 'IDLE' | 'CHOOSING_MENU' | 'AWAITING_PAYMENT';
Enter fullscreen mode Exit fullscreen mode

This eliminates runtime string typos and makes invalid state transitions impossible at compile time. Each state maps to a dedicated controller function — clean, modular, testable.

3. The Payment Bridge (Paystack + EventBus)

When a user types PAY, the backend compiles a checkout payload using environment variable abstraction:

const callbackUrl = process.env.PAYSTACK_CALLBACK_URL;
Enter fullscreen mode Exit fullscreen mode

The device ID gets tucked into Paystack's transaction metadata. When payment clears, Paystack fires a server-to-server webhook. The backend verifies the HMAC signature, then fires an internal eventBus emission:

eventBus.emit('payment:confirmed', { deviceId });
// → resets MongoDB user state to 'IDLE'
Enter fullscreen mode Exit fullscreen mode

Result: What production looks like

  • Concurrent isolation: stress-tested at 50 simultaneous sessions — zero data leakage
  • Environment parity: zero manual code changes between local dev and production

I chose to explicitly extends Document on the cart interface rather than using InferSchemaType. It's more verbose, but the explicitness of knowing exactly what methods are available on each document was worth the tradeoff for me. Curious whether others have a strong opinion here.


👉 Test the Live Demo Here (Running in Paystack Test Mode — feel free to use the dummy test cards to complete an order!)

💻 GitHub Source Code

What's next

Implementing Cron jobs to clear pending transactions after 24 hours.

When you build payment loops, do you lean on webhook-driven event buses, short polling, or WebSockets? I'd genuinely like to know — there are real tradeoffs I'm still thinking through.

Top comments (0)