YumKiosk YumKiosk Docs
Website Agent login Owner panel
Architecture

Payment stack

How Stripe Connect and Stripe Billing work side by side.

Payment stack

YumKiosk has two completely separate Stripe relationships that often confuse new integrators. Understanding the split is essential before you touch anything in the payments flow. This page walks through both and explains why they're separated.

The two Stripe accounts

1. Stripe Connect (customer → restaurant)

When a customer pays at a kiosk, the money should go to the restaurant's bank account, not YumKiosk's. YumKiosk is a platform, not a payment processor — we facilitate the transaction but we don't touch the funds.

We use Stripe Connect Express accounts for this. Each owner connects their own Stripe account during onboarding (or creates a new Connect Express account through our embedded onboarding flow), and from that point on, all customer-facing charges at the kiosks under that owner go directly to their Connect account. Stripe payouts run on whatever schedule the owner configured in their Stripe dashboard — typically daily.

YumKiosk can optionally take an application fee on each Connect transaction — a small flat or percentage fee that gets routed back to our platform account for each charge. This is how a pure platform would monetize if it didn't run agent hours. For YumKiosk today, we charge zero application fees on Connect transactions — all of our revenue comes from the separate SaaS subscription on the other side. Owners keep 100% of their customer revenue minus Stripe's standard processing fees (~2.9% + $0.30 per card).

2. Stripe Billing (YumKiosk → restaurant)

Separately, YumKiosk bills the restaurant (the owner) for using the platform. This is a traditional SaaS subscription relationship: we set up a customer in our own Stripe account, attach a subscription to a plan product, and generate monthly invoices.

We use Laravel Cashier (the Stripe-specific variant) to wire this up. Cashier handles subscription lifecycle, prorations, proration credits, and webhook processing, giving us a clean Eloquent interface over Stripe's REST API. See App\Models\Owner for the Cashier integration.

Why split them

You could imagine a simpler architecture where YumKiosk is the merchant of record for everything — customers pay YumKiosk, YumKiosk keeps its cut and pays the restaurant minus fees. Some platforms do this. We don't, for three reasons:

  1. Owner trust: restaurants have a direct Stripe relationship they can see, manage, and reconcile with their bookkeeping. No "trust us, we'll pay you on time".
  2. Regulatory simplicity: YumKiosk isn't licensed as a money transmitter in every US state. Having the customer's money flow through our account would require that licensure.
  3. Cash flow: restaurants get paid fast by Stripe (usually next day), not on whatever schedule our platform decides. This is important for small operators running tight cash flows.

The tradeoff is complexity — two Stripe integrations, two dashboards, two sets of webhooks, and ongoing education for new owners about the difference.

Webhook handling

Both Stripe accounts send webhooks to us. We handle them at different endpoints:

  • SaaS billing webhooks: POST https://agent.yumkiosk.com/stripe/webhook (Laravel Cashier's built-in handler, extended with our App\Http\Controllers\WebhookController)
  • Connect webhooks: POST https://agent.yumkiosk.com/stripe/connect/webhook (custom handler in App\Http\Controllers\StripeConnectWebhookController)

Both are verified using Stripe's Stripe-Signature header with their respective endpoint signing secrets (stored in config/services.php).

Key events we care about:

SaaS billing

  • invoice.paid → mark the owner's subscription as current
  • invoice.payment_failed → show a dunning banner in the owner panel
  • customer.subscription.updated → update plan tier in our database
  • customer.subscription.deleted → downgrade to the free tier

Connect

  • account.updated → update the Connect account status (onboarding complete, under review, restricted)
  • payment_intent.succeeded → mark the customer order as paid, update the session state
  • payment_intent.payment_failed → mark the order as failed, show an error on the kiosk
  • application_fee.created → record the (currently zero) platform fee for audit

In-session payment flow

When the agent clicks Send to payment during a session:

  1. Laravel creates a Stripe PaymentIntent on the owner's Connect account, with the correct amount in cents.
  2. The PaymentIntent's client secret is broadcast to the kiosk over WebSocket.
  3. The kiosk initializes Stripe.js and either presents a card entry form or, if Stripe Terminal is paired, waits for the card reader to read a card.
  4. Customer pays. Stripe.js confirms the PaymentIntent against Stripe's API.
  5. Stripe webhooks us with payment_intent.succeeded, we update the session state to completed, and the customer sees the thank-you screen.

The entire payment takes 3–5 seconds on a good connection. If the card is declined, the kiosk shows a message and stays in payment mode so the customer can try a different card.

Refunds

Refunds are triggered from the owner panel (Operations → Orders → [order] → Refund). The owner picks a reason, optionally a partial amount, and confirms. Laravel calls stripe.Refund.create() on the Connect account, and the refund shows up in the owner's Stripe dashboard immediately. The order in YumKiosk is marked as refunded and shows the refund amount and reason.

Agents cannot initiate refunds — that's deliberately reserved for managers to prevent abuse.