Cherry Bomb Vending Machine
buildingFull-stack / IoT
A QR-code vending machine for handmade goods: one Pix covers a multi-item cart, and a payment webhook is the only thing allowed to move stock.
- FastAPI
- Python 3.11
- PostgreSQL
- psycopg2
- Mercado Pago (Pix)
- Cloudinary
- Tailwind CSS
- Vanilla JS
- Railway
- ESP32 / MQTT
● The repository is public, but this case study is published without a link while a credential found in its documentation is rotated.
// the case
- problem
- Selling handmade pieces in residential buildings means standing by a table for hours or trusting an honour box. A conventional vending machine does not fix it: a card reader charges a monthly fee and a per-transaction cut that an R$ 8.50 hair clip cannot absorb, and cash has to be collected and secured. The machine needed to sell twenty distinct items, unattended, at R$ 5.50 to R$ 24.50, with no hardware in the customer's path.
- solution
- The payment terminal was removed from the machine and replaced by a sticker. A QR code on the glass opens a mobile storefront that mirrors the machine's physical layout — a 4×5 grid where every card is a real coil, addressed A1 to E4. The customer fills a bag across several slots and pays one Pix for the whole cart: the backend totals it server-side, asks Mercado Pago for a single charge, and waits. Nothing about the order is trusted until the webhook reports approved.
- impact
- Not measured, and deliberately not claimed. The software path runs in production end to end — storefront, Pix charge, confirmation webhook, stock subtraction — but physical dispensing is not implemented: the ESP32 firmware file is empty and there is no MQTT publisher. The honest summary is a payment and inventory system working in production, attached to a machine that cannot yet turn its coils. Any figure about units sold would be fiction.
// my role
Sole author, over four days in April 2026 — product definition, FastAPI backend, PostgreSQL schema, Mercado Pago integration and webhook, Cloudinary image pipeline, the customer storefront, the admin panel, the visual identity, and deployment. The hardware protocol is mine too; the firmware that will consume it is not written yet.
// overview
The project starts from a constraint rather than a product idea. In Brazil, Pix is instant, free for individuals and already installed on every customer's phone as their banking app — which makes the phone a better payment terminal than anything that could be bolted to a vending machine. So the machine was designed with no payment hardware at all. The consequence is that almost the entire product is a web application, and the physical machine is reduced to a grid of coils, a controller and a QR sticker. The 4×5 grid is not a design flourish: slot C2 in the interface is coil C2 in the machine, which is what lets a single Pix drive several motors in one order.
The interface commits hard to one context — a stranger, standing at a machine, holding a phone, probably in a hurry. It is mobile-only by construction, since the only route in is the QR code on the glass. There are no hover states anywhere in the stylesheet, only an active state with a press-down scale, because nothing on a touchscreen hovers. The visual language is Y2K pop-punk neobrutalism: four colours and no more, 2px black borders on everything, hard offset shadows with zero blur, no rounded corners. It reads as a sticker on a machine rather than an e-commerce checkout, which is the point — the customer should feel like they are operating a vending machine, not entering a store.
// engineering notes
One Pix for a cart, not one per item
The natural implementation — a charge per product — would multiply round-trips to Mercado Pago and force the customer through several bank confirmations for a single purchase. Instead the backend totals the cart itself using decimal arithmetic with half-up rounding before creating one charge, and stores the item list as JSON alongside the payment record. That stored list is what the approval step later reads to know which coils to turn, which is why a single Pix can drive several motors.
The webhook is the only thing that can move stock
Stock is never decremented on the client, at checkout, or on any optimistic path. The front-end caps the plus button at the live stock figure purely as courtesy; the authoritative write happens only when Mercado Pago's webhook reports approved, in one guarded SQL update per item. The guard floors the result at zero, so a duplicated webhook — which payment providers do send — cannot drive inventory negative. A customer who abandons the page mid-payment never removes stock that was not paid for.
Designed for a stranger holding a phone at a machine
The interface assumes one context and refuses to serve any other. Mobile-only, because the only entry point is a QR code on glass. No hover rules exist in the stylesheet at all — only an active state that presses the element into its own shadow, so every tap answers back. Sold-out slots are not hidden but dimmed and desaturated with touch removed, so the on-screen grid keeps matching the physical machine the customer is looking at. Quantity control collapses into a strip inside the card, which keeps a twenty-slot grid usable on a phone without opening a modal per product.
Restocking without a developer
A password-protected admin panel lets the operator rename products, change prices, set stock and photograph items straight from their phone; the upload goes through the backend to Cloudinary, is cropped server-side to a square with automatic gravity, and the resulting URL is written back to the product row. Without this the project would have needed a developer present at every restock, which would have made the machine unusable in practice.
The hardware boundary was specified before the hardware existed
The MQTT contract — topic, JSON shape, per-motor quantity and turn duration — was written and fixed while the ESP32 was still in shipping. That let the whole payment and inventory path be built, deployed and tested against a defined seam instead of a guess. It is also the honest boundary of what is done: the contract exists, the firmware does not.