Integrations

Start here: pick your path through the optional integrations (framework × store × worker model × events-in), then a 15-minute executable tutorial from a Stately export to a multi-worker FastAPI service on SQLite and Redis.

The core library has zero runtime dependencies, and that never changes. Everything that talks to a framework lives in optional packages under xstate_statemachine.contrib, installed one pip extra at a time (the full list, with status, is on Integration extras). Importing an integration without its extra raises MissingExtraError (an ImportError) naming the exact pip install command.

This page is the entry point: pick your path, then do the fifteen-minute tutorial.

Pick your path

Answer four questions — web framework, store, worker model, how events arrive — and read the pages at the leaf, in order. Every framework combines with every store, but not every worker model: residents (long-lived in-memory interpreters) exist only on the ASGI registry (Starlette / FastAPI / Litestar), not under Flask or Django — use per-request there. Celery workers combine with any store, and Celery Beat replaces DueTimerScanner as the timer process.

flowchart LR
    Q1{Web framework?}
    Q1 -->|FastAPI| FA["pip install xstate-statemachine[fastapi]<br/>read: FastAPI"]
    Q1 -->|Starlette| ST["[starlette]<br/>read: Starlette"]
    Q1 -->|Litestar| LS["[litestar]<br/>read: Litestar"]
    Q1 -->|Django| DJ["[django] + [drf] / [channels]<br/>read: Django, DRF & Channels"]
    Q1 -->|Flask| FL["[flask]<br/>read: Flask"]
    Q1 -->|none| Q2
    FA --> Q2
    ST --> Q2
    LS --> Q2
    DJ --> Q2
    FL --> Q2
    Q2{Store?}
    Q2 -->|one host| SQ["SQLiteStore (core)<br/>read: Persistence"]
    Q2 -->|several hosts| RD["[redis]<br/>read: Redis"]
    Q2 -->|SQL / Django ORM| SQL["[sqlalchemy] / [django]<br/>read: SQLAlchemy, Django"]
    SQ --> Q3
    RD --> Q3
    SQL --> Q3
    Q3{Worker model?}
    Q3 -->|per request| ACT["create → act → persist → discard<br/>read: Guarantees"]
    Q3 -->|long-lived| RES["resident() / actors<br/>read: Starlette, Actors"]
    Q3 -->|Celery workers| PL3["[celery]<br/>read: Celery"]
    ACT --> Q4
    RES --> Q4
    PL3 --> Q4
    Q4{Events in?}
    Q4 -->|HTTP| HT["Idempotency-Key + inbox<br/>read: Security"]
    Q4 -->|timers| TM["DueTimerScanner<br/>read: Persistence"]
    Q4 -->|broker| PL4["[kafka] / [rabbitmq] / [nats] / [sqs] / [redis]<br/>read: Brokers"]
Leaf Install Read, in order
FastAPI [fastapi] FastAPI → Starlette (the registry it re-exports) → Pydantic
Starlette [starlette] Starlette
Litestar [litestar] Litestar
Django [django] + [drf] / [channels] Django → DRF & Channels
Flask [flask] Flask → SQLAlchemy
One host core Persistence → Snapshots
Several hosts [redis] Redis → Persistence
SQLAlchemy / Django ORM [sqlalchemy] / [django] SQLAlchemy / Django → Persistence
Per request — Guarantees → Production characteristics
Long-lived — (ASGI only: Starlette / FastAPI / Litestar) Starlette (resident()) → Actors
Celery workers [celery] Celery (Celery Beat + DurableTimerScheduler is the after scheduler)
HTTP — Security (principal, Idempotency-Key)
Timers core Persistence (DueTimerScanner) → Delayed transitions
Broker core eda + [cloudevents] envelope, consumer loop, outbox, DLQ, sagas: Event-driven; broker adapters [kafka] / [rabbitmq] / [nats] / [sqs] (Redis Streams in [redis]): Brokers
LLM agents [agents] LLM agents → Security (baseline row X0.13: agent safety)
Testing [testing] pytest — xstate_machine marker and xsm_* fixtures
Observability [observability] Observability (#273) → Live inspector (core, #274)

15-minute tutorial

From a chart drawn in the Stately editor to a FastAPI service that runs on SQLite, deduplicates retries, and moves to Redis with one line. Every code block below runs in CI (the FastAPI and Redis blocks in the cells that install those extras).

pip install "xstate-statemachine[fastapi,redis]" httpx "fakeredis[lua]"

fakeredis[lua] is only for step 5, which runs the Redis step in-process so the tutorial works without a server; the [lua] extra matters – the store’s atomic saves are Lua scripts and plain fakeredis has no Lua engine (you would see the Redis server has no Lua scripting).

Want the finished result instead? xsm new --template fastapi my_service scaffolds the complete fastapi_orders example with its tests; --template flask does the same for the flask_wizard example (see CLI).

1. Export the chart

Draw the chart in the Stately editor and export it as JSON (how, and what the export must contain). The tutorial writes the exported chart out directly at the top of step 2.

2. Validate, inspect and generate

import json, os, sys, tempfile
from pathlib import Path

from xstate_statemachine.cli.__main__ import main as xsm

os.chdir(tempfile.mkdtemp())
chart = {
    "id": "order",
    "version": "1",
    "initial": "cart",
    "states": {
        "cart": {
            "on": {
                "CHECKOUT": "awaitingPayment",
            },
        },
        "awaitingPayment": {
            "on": {
                "PAY": "paid",
            },
            "after": {
                "900000": "expired",
            },
        },
        "paid": {
            "type": "final",
        },
        "expired": {
            "type": "final",
        },
    },
}
Path("order.machine.json").write_text(json.dumps(chart, indent=2))


def run(*argv):  # the same as typing `xsm ...` in a shell
    sys.argv = ["xsm", "--plain", *argv]
    try:
        xsm()
    except SystemExit as exc:
        assert not exc.code, (argv, exc.code)


run("validate", "order.machine.json")  # builds it with the real library
run("inspect", "order.machine.json")  # state tree, events, timers
flags = [
    "-t",
    "pythonic-class",
    "--with-api",
    "--with-models",
    "--with-tests",
    "-o",
    "generated",
]
run("gt", "order.machine.json", *flags, "-f")
for name in ("order_api.py", "order_models.py", "test_order.py"):
    assert Path("generated", name).is_file()  # router, event models, tests
run("gt", "--check", "order.machine.json", *flags)  # CI: fails when stale

--with-api writes order_api.py, an editable FastAPI router with one typed route per event, and --with-models writes order_models.py, one EventModel per event plus a context model (CLI). Step 3 shows the same pieces inline, so you can see what the generated modules wire together.

3. Run it as a FastAPI app on SQLite

Each request loads the order’s snapshot, runs one interpreter, saves it with the version it loaded and discards the interpreter — so there is nothing to lose when a worker dies.

import tempfile
from pathlib import Path
from typing import Literal

from fastapi import FastAPI
from fastapi.testclient import TestClient

from xstate_statemachine import create_machine
from xstate_statemachine.contrib.fastapi import (
    StatechartRegistry,
    StatechartRouter,
    instrument_app,
)
from xstate_statemachine.contrib.pydantic import EventModel, events_union
from xstate_statemachine.persistence import SQLiteStore


class Checkout(EventModel):
    type: Literal["CHECKOUT"] = "CHECKOUT"


class Pay(EventModel):
    type: Literal["PAY"] = "PAY"
    card_token: str


order = create_machine(
    {
        "id": "order",
        "version": "1",
        "initial": "cart",
        "states": {
            "cart": {
                "on": {
                    "CHECKOUT": "awaitingPayment",
                },
            },
            "awaitingPayment": {
                "on": {
                    "PAY": "paid",
                },
            },
            "paid": {
                "type": "final",
            },
        },
    },
    event_schemas=events_union(Checkout, Pay),
)


def authorize(conn, *, name, key, event):
    return conn.headers.get("x-customer") is not None  # your auth here


store = SQLiteStore(str(Path(tempfile.mkdtemp()) / "orders.db"))
registry = StatechartRegistry(store)
registry.register("order", order, authorize=authorize)
app = FastAPI()
app.include_router(StatechartRouter(registry, "order", prefix="/orders"))
instrument_app(app, registry)

with TestClient(app) as client:
    ann = {
        "x-customer": "ann",
    }
    client.post(
        "/orders/o1/send",
        json={
            "type": "CHECKOUT",
        },
        headers=ann,
    )
    r = client.post(
        "/orders/o1/send",
        json={
            "type": "PAY",
            "card_token": "t",
        },
        headers=ann,
    )
    assert r.status_code == 200 and r.json()["state"] == "paid"
    assert client.get("/orders/o1", headers=ann).json()["state"] == "paid"
assert store.load("order.o1").version == 2  # two committed changes

4. Deduplicate retries with an inbox

A client that times out retries. Give the registry an inbox and a principal: every request then runs with an IdempotencyPlugin scoped to the caller, and a repeated Idempotency-Key returns the original receipt instead of acting twice. SQLiteInbox(store) shares the store’s connection, so the mark commits with the snapshot.

import tempfile
from pathlib import Path

from fastapi import FastAPI
from fastapi.testclient import TestClient

from xstate_statemachine import MachineLogic, create_machine
from xstate_statemachine.contrib.fastapi import (
    StatechartRegistry,
    StatechartRouter,
    instrument_app,
)
from xstate_statemachine.persistence import SQLiteInbox, SQLiteStore

order = create_machine(
    {
        "id": "order",
        "initial": "open",
        "context": {
            "n": 0,
        },
        "states": {
            "open": {
                "on": {
                    "ADD": {
                        "actions": "add",
                    },
                },
            },
        },
    },
    logic=MachineLogic(
        actions={
            "add": lambda i, ctx, e, a: ctx.update(n=ctx["n"] + 1),
        }
    ),
)
store = SQLiteStore(str(Path(tempfile.mkdtemp()) / "orders.db"))
registry = StatechartRegistry(
    store,
    inbox=SQLiteInbox(store),  # ← the idempotency inbox
    principal=lambda conn: conn.headers["x-customer"],  # its scope (X0.2)
)
registry.register(
    "order", order, authorize=lambda conn, **kw: "x-customer" in conn.headers
)
app = FastAPI()
app.include_router(StatechartRouter(registry, "order", prefix="/orders"))
instrument_app(app, registry)

with TestClient(app) as client:
    h = {
        "x-customer": "ann",
        "Idempotency-Key": "add-1",
    }
    first = client.post(
        "/orders/o1/send",
        json={
            "type": "ADD",
        },
        headers=h,
    )
    again = client.post(
        "/orders/o1/send",
        json={
            "type": "ADD",
        },
        headers=h,
    )
    assert first.json()["changed"] and again.json()["duplicate"] is True
assert store.load("order.o1").version == 1  # acted once

5. Switch the store to Redis

For several hosts, replace the two store lines. In production pass a URL — RedisStore("redis://redis:6379/0", prefix="orders") and RedisInbox(...) with the same arguments; here fakeredis[lua] stands in so the block runs anywhere (the [lua] extra is required: the store’s saves are Lua scripts). Nothing else changes.

import fakeredis
from fastapi import FastAPI
from fastapi.testclient import TestClient

from xstate_statemachine import MachineLogic, create_machine
from xstate_statemachine.contrib.fastapi import (
    StatechartRegistry,
    StatechartRouter,
    instrument_app,
)
from xstate_statemachine.contrib.redis import RedisInbox, RedisStore

order = create_machine(
    {
        "id": "order",
        "initial": "open",
        "context": {
            "n": 0,
        },
        "states": {
            "open": {
                "on": {
                    "ADD": {
                        "actions": "add",
                    },
                },
            },
        },
    },
    logic=MachineLogic(
        actions={
            "add": lambda i, ctx, e, a: ctx.update(n=ctx["n"] + 1),
        }
    ),
)
r = fakeredis.FakeRedis()  # redis.Redis.from_url(...) in production
store = RedisStore(r, prefix="orders")
registry = StatechartRegistry(
    store,
    inbox=RedisInbox(r, prefix="orders"),
    principal=lambda conn: conn.headers["x-customer"],
)
registry.register(
    "order", order, authorize=lambda conn, **kw: "x-customer" in conn.headers
)
app = FastAPI()
app.include_router(StatechartRouter(registry, "order", prefix="/orders"))
instrument_app(app, registry)

with TestClient(app) as client:
    h = {
        "x-customer": "ann",
        "Idempotency-Key": "add-1",
    }
    for _ in range(3):
        client.post(
            "/orders/o1/send",
            json={
                "type": "ADD",
            },
            headers=h,
        )
record = store.load("order.o1")
assert record.version == 1  # three deliveries, one change

6. Run four workers

Because no worker holds an order in memory, the same app runs under uvicorn app:app --workers 4 unchanged, on SQLite (one host) or Redis (many). Racing writers get one winner and a 409, never a lost update. Start exactly one python app.py --role scheduler to wake persisted after timers. The example’s loadtest.py --workers 4 --requests 200 fires 200 concurrent payments at one order and checks that exactly one charge happened; this step has no code block of its own — the runnable example, the numbers and the Windows caveat (--workers N can stall in accept(); use separate single-worker processes there) are in the FastAPI guide’s multi-worker section.

7. Test it, then the coverage gate and live inspector

  • Tests — pip install "xstate-statemachine[testing]" and mark a test @pytest.mark.xstate_machine("order.json"): the plugin hands you a started xsm_interp, a xsm_clock for the after timers, xsm_ran for the actions that fired and xsm_snapshot for file-backed snapshot assertions (pytest guide). xsm gt order.json -t pytest --fixtures scaffolds such a module.
  • Live inspector — xsm inspect order.json --live --open streams the machine to the Stately Inspector (or the built-in page) over the @statelyai/inspect protocol; xsm sim --record / xsm replay --live record and replay; under Starlette/FastAPI, mount_inspector(app, registry, debug=True) serves it over WebSocket (Live inspector). Metrics, traces and log context come from [observability] (Observability).

  • Coverage gate — pytest --xsm-coverage --xsm-fail-under-state-coverage=90 (and --xsm-fail-under-transition-coverage=N) records every machine the session runs and fails the build below the threshold (State & transition coverage, #270).

What you get / what you don’t

You get You do not get
Every committed transition is saved with an expected version; racing writers produce one winner and a 409. Exactly-once side effects. Delivery is at-least-once; pair it with the inbox and make actions idempotent.
Idempotency-Key replays return the stored receipt, scoped to the caller’s principal. A key shared across principals — one caller can never replay another’s receipt.
after timers survive restarts as persisted deadlines, woken by one scanner. Timers that fire at a deadline; they fire not before it, when the scanner next runs.
Validated event bodies and RFC 9457 problems; context is never exposed unless you serialise it. Authentication. authorize= is required on every route and it is yours to write.
Zero runtime dependencies in the core, extras one at a time. Cross-worker push: SSE/WebSocket fan-out is per process, and residents are single-process (Starlette guarantees).
after timers woken by one DueTimerScanner / Beat process. Timers in every worker — run exactly one scheduler. On Windows, uvicorn --workers N can stall a request in accept(); run single-worker processes behind a proxy (FastAPI troubleshooting).

The full crash-consistency specification is Guarantees; the trust model and each baseline item’s enforcing test are in Security.

Where next