webhook_payment — a payment driven by webhooks¶
What it does¶
A payment lifecycle driven by Stripe-style webhook events. POST /orders creates one payment
and returns its id; the payment provider later calls POST /webhooks/stripe with
payment_intent.succeeded or payment_intent.payment_failed, and the payment moves on. If no
webhook arrives within 15 seconds, the payment is abandoned. GET /orders/{id} shows where it
is. simulate.py plays the provider, so no account is needed:
python -m examples.webhook_payment.simulate # happy path
python -m examples.webhook_payment.simulate fail # payment declined
python -m examples.webhook_payment.simulate timeout # no webhook: abandoned after ~15 s
python -m examples.webhook_payment.simulate dedup # the same webhook delivered twice
The machine¶
stateDiagram-v2
[*] --> Initializing
state "Initializing" as Initializing
Initializing : on enter#58; setup_order
state "AwaitingPayment" as AwaitingPayment
AwaitingPayment : timeout#58; 15
state "Fulfilling" as Fulfilling
Fulfilling : on enter#58; start_fulfillment
state "Done" as Done
Done : on enter#58; on_done
Done : outcome#58; success
state "Failed" as Failed
Failed : on enter#58; on_failed
Failed : outcome#58; failed
state "Abandoned" as Abandoned
Abandoned : on enter#58; on_abandoned
Abandoned : outcome#58; abandoned
state "SvcError" as SvcError
SvcError : on enter#58; on_svc_error
SvcError : outcome#58; failed
Initializing --> AwaitingPayment
AwaitingPayment --> Fulfilling : PaymentSucceeded
AwaitingPayment --> Failed : PaymentFailed
AwaitingPayment --> Abandoned : Timeout
Fulfilling --> Done
Fulfilling --> SvcError : error
Done --> [*]
Failed --> [*]
Abandoned --> [*]
SvcError --> [*]
What it shows:
A durable timeout (
timeout 15onAwaitingPayment): the timer is committed with the state, so the payment is abandoned on time even across restarts — no cron job.Deduplication for free: the webhook handler sends
Event(id=<the provider's event id>), and the engine records the ids it has processed, so a redelivered webhook is a no-op.on error: ifstart_fulfillmentraises (the fulfilment service down), the payment goes toSvcErrorinstead of dead-lettering. A declined payment is a modelled event, not an error.
How it is built¶
Two processes share the store and the queue, two SQLite files (payments.db,
payments-queue.db):
app.py— the API. FastAPI, with plaindefhandlers.create_ordercallsrunner.create(...), which commits the execution and queues itsStart; it never runs the machine’s actions.stripe_webhookmaps the payload to anEventand callsrunner.send(...), which only publishes it, and answers204.get_orderreads the store.worker.py— the worker.runner.worker().run(stop): claims the next message, runs the machine for it, commits, acknowledges — and, when idle, fires the due timers.simulate.py— sends fake provider payloads over HTTP.
A webhook, end to end:
sequenceDiagram
actor P as Provider
participant A as API (FastAPI handler)
participant T as queue (SqliteTransport)
participant W as worker
participant S as store (SqliteStore)
P->>A: POST /webhooks/stripe (succeeded)
A->>T: publish(order id, PaymentSucceeded, id = provider's event id)
A-->>P: 204 — nothing has run yet
W->>T: claim
T-->>W: lease(PaymentSucceeded)
W->>S: load, dedupe on the event id
W->>W: AwaitingPayment to Fulfilling to Done (start_fulfillment, on_done)
W->>S: commit
W->>T: ack
And the timeout, when no webhook comes:
sequenceDiagram
participant W as worker
participant S as store
participant T as queue
Note over S: entering AwaitingPayment committed a timer, due in 15 s
W->>S: due_timers(now)
W->>T: publish(Timeout)
W->>T: claim
T-->>W: lease(Timeout)
W->>S: AwaitingPayment to Abandoned, commit
How it runs¶
Semantics: asynchronous —
DistributedRunnerand a worker. The provider wants a fast2xx, and a slow action (a fulfilment call) must not hold the webhook open: the handler hands the event to the queue and answers, and the worker advances the machine. The price is eventual consistency:GET /orders/{id}may lag a webhook by a few milliseconds. A protocol that needs the new state in the response would use aDurableRunner’sprocessinstead.Execution model: background, in both processes. FastAPI runs
defhandlers in a thread pool, outside its event loop, which is where a sync runner belongs. (async defhandlers would callAsyncDistributedRunnerinstead: a sync runner refuses a call from inside a running loop.)One worker. SQLite has one writer per file, so more workers add contention, not throughput. The same code scales out unchanged on a server backend (Postgres, Redis, …).
Run it¶
pip install -r examples/webhook_payment/requirements.txt
uvicorn examples.webhook_payment.app:app # terminal 1 — the API
python -m examples.webhook_payment.worker # terminal 2 — the worker
python -m examples.webhook_payment.simulate # terminal 3