Dogfooding Payments
We run our own payment system on dispatched.work. Checkout, webhooks, balance top-ups, email notifications — it’s all just workflows on the operator tenant. Same engine, same step syntax, same secrets store as everyone else.
Why we did this
Most platforms hard-code their billing. Payment SDK calls in controllers, webhook handlers in middleware, emails fired from background jobs. None of that code actually uses the product.
We wanted to use our own product for something that actually matters. So instead of building a bespoke billing backend, we made the engine do what it already does — orchestrate HTTP calls — and pointed it at a payment provider.
The operator is just another tenant. It has a tenant ID, secrets, and workflows. The engine fires hooks into those workflows when billing events happen, and the workflows take it from there.
What we built vs. what the workflows do
To make this work, we needed to build a small amount of glue in the engine. Everything else is handled by workflows — the same kind any tenant can create.
What we built in the engine
Balance tracking. Each tenant has a balance in milliseconds of run time, plus an append-only ledger that records every credit and debit. This is the only billing-specific code in the engine.
Hooks. When a tenant clicks “Buy” in the billing UI, the engine fires on_purchase_requested. When a balance hits zero, on_balance_depleted. These hooks trigger workflows on the operator tenant. Think of them as internal events that start a workflow run.
A small internal API. A handful of endpoints like /api/internal/billing/credit and /api/internal/tenant/set-external-customer. These are what the workflows call back into to actually update balances and store customer IDs. They’re just regular HTTP endpoints, protected by an API key.
This is the part that you’d also need to build if you were setting up payments for your own app. Dispatched handles the orchestration — calling your payment provider, receiving webhooks, sending emails — but it needs somewhere to write the results. For us, that’s our internal API. For you, it would be endpoints on your own backend. A POST /api/credits that adds balance to a user account, a POST /api/customers that links a payment provider ID — whatever your app needs.
What the workflows handle
Everything else: creating checkout sessions, validating webhook signatures, crediting accounts, looking up customer emails, sending notifications. No payment SDK, no webhook middleware, no email library in the engine.
The payment flow
Pick your payment provider — the engine doesn’t care which one you use.
create-checkout-session
This workflow fires when a tenant initiates a purchase. It handles both new and returning customers:
-
create_customer — only runs if the tenant has no Mollie customer yet (checked with a
whenguard onexternal_customer_id). POSTs to Mollie’s/v2/customersendpoint to create one, passing the tenant ID as metadata. -
save_customer — runs after
create_customer, also only for new customers. Calls back into our app (via the internal API) to link the Mollie customer ID to the tenant record so we don’t create duplicates on the next purchase. This is the kind of step where your workflow would call your own backend instead. -
pay_new_customer — runs after
save_customerfor first-time buyers. Creates a Mollie payment via/v2/paymentsusing the freshly created customer ID. Sets thewebhookUrlso Mollie knows where to send status updates, and passes the tenant ID and minutes in the payment metadata. -
pay_existing_customer — runs instead of the above three steps for returning customers. Same payment creation call, but uses the existing Mollie customer ID directly. The
whenguards make these two paths mutually exclusive — only one branch executes per run.
Both payment steps include redirect URLs so the tenant lands back on the billing page after checkout, and a webhookUrl that points back to the operator’s payment-webhook workflow.
payment-webhook
When Mollie sends a webhook notification, this workflow picks it up. The trigger has a Lua when condition that validates the webhook signature — but only if MOLLIE_WEBHOOK_SECRET is configured. If the secret isn’t set (handy in test mode), the webhook is accepted without verification.
Two steps handle the rest:
-
fetch_payment — Mollie webhooks only send the payment ID, not the full details. This step fetches the complete payment object from
/v2/payments/{id}so we can check what actually happened. -
credit_balance — runs after
fetch_payment, but only if the payment status is"paid"(checked with awhenguard). Calls our internal API to credit the tenant’s balance with the number of minutes from the payment metadata. In your setup, this would be a call to your own backend — whatever endpoint updates a user’s account after a successful payment.
Payments are exactly the kind of thing where retries and idempotency matter. If the credit_balance step fails because of a network blip, dispatched retries it automatically. And because every step gets an Idempotency-Key header, your backend can make sure a retried request doesn’t credit the account twice. We use this ourselves — the internal billing API checks the idempotency key before applying a credit.
balance-depleted-alert
When a tenant’s balance hits zero, the engine fires on_balance_depleted and this two-step workflow handles notifications:
-
get_customer — fetches the customer record from Mollie using the
external_customer_id. We don’t store emails locally, so this is how we find out where to send the notification. -
send_alert — waits for
get_customerto finish (usingafter), then POSTs to the Resend API to send an email telling the tenant their balance is empty and runs are paused.
create-checkout-session
This workflow fires when a tenant initiates a purchase. It has a single step:
-
create_session — POSTs to Stripe’s
/v1/checkout/sessionsAPI to create a Checkout Session. It passes the tenant ID and number of minutes as Stripe metadata so we can match the payment back to the right account later. The Stripe secret key comes from{{ secrets.STRIPE_SECRET_KEY }}, same as any workflow secret. Stripe returns a checkout URL and the browser redirects there.
Stripe handles customer creation as part of the checkout flow, so there’s no separate step for it.
payment-webhook
When Stripe sends a webhook, this workflow picks it up — but only if the signature checks out. The trigger has a when condition written in Lua that parses the stripe-signature header, reconstructs the signed payload, and verifies the HMAC-SHA256. If the signature is wrong, the trigger rejects the request and no run starts.
Two steps run in parallel:
-
credit_balance — fires only when the event type is
checkout.session.completed. Calls our internal API to credit the tenant’s balance with the number of minutes from the Stripe metadata. Your equivalent would be whatever endpoint updates a user’s account after payment. -
save_customer — also fires on
checkout.session.completed, but only when a Stripe customer ID is present. Calls our internal API to link the Stripe customer to the tenant record. Again, you’d replace this with a call to your own backend.
Both steps use when guards to decide whether to run — the same conditional execution pattern you’d use in your own workflows.
balance-depleted-alert
When a tenant’s balance hits zero, the engine fires on_balance_depleted and this two-step workflow handles notifications:
-
get_customer — fetches the customer record from Stripe using the
external_customer_id. We don’t store emails locally, so this is how we find out where to send the notification. -
send_alert — waits for
get_customerto finish (usingafter), then POSTs to the Resend API to send an email telling the tenant their balance is empty and runs are paused.
Why this matters
We catch our own bugs. Every payment exercises the engine end to end — HTTP steps, expressions, Lua conditions, secrets, step dependencies. If the engine has a problem, our billing has a problem. We find out fast.
Switching providers is just a new workflow. The toggle above isn’t hypothetical — both sets of workflows exist. The engine doesn’t know or care what’s on the other end of the HTTP call. To switch from Mollie to Stripe (or the other way around), you deploy different operator workflows. No code changes.
Billing is inspectable. The payment workflows show up in the same dashboard as everything else. Same run events, same step logs, same debugging tools. No hidden code paths.
The engine stays small. Adding a payment provider means adding a YAML file, not a new dependency.
Data boundary
We store exactly one payment-related field locally: external_customer_id on the tenant — a foreign key to your payment provider. Everything else (email, payment methods, transaction history) lives in the provider and gets fetched on demand by workflows when they need it.
This keeps the local database minimal and means we’re not holding onto sensitive payment data.
Building your own payment workflows
If you want to set up something similar, here’s what you’d need:
On your side: A few HTTP endpoints in your own app that the workflows can call back into — things like crediting a user’s account, storing a customer ID, or marking an order as paid. These are just regular API endpoints that receive JSON. The workflow handles the orchestration; your backend handles the business logic.
On dispatched: Workflows that tie everything together. The billing workflows above are real-world examples of patterns available to every tenant:
-
Conditional branching —
whenguards that route new vs. returning customers through different steps -
Webhook handling — signature validation with Lua
whenconditions on triggers -
Step dependencies —
afterfor sequential orchestration (fetch, then check, then credit) - Retries — transient failures (5xx, timeouts) are retried automatically with backoff, so a flaky payment provider API doesn’t mean a lost payment
-
Idempotency — every step execution gets a unique
Idempotency-Keyheader, so retried requests don’t cause duplicate charges or double credits -
Secret resolution —
{{ secrets.NAME }}for API keys - Cross-service orchestration — payment provider, email service, and your own API in one workflow
The Integrating Services guide covers the request and response patterns in detail.