Critical flows¶
Sequence diagrams for the 5 flows that fail most often on first implementation, plus the state machines for Order and Invitation. Each flow names the SDK primitives involved.
1. Public signup + login¶
sequenceDiagram
autonumber
actor C as Client
participant R as auth.router
participant S as UserService
participant U as PasswordUtils
participant J as JWTUtils
participant DB as Postgres
C->>R: POST /auth/signup {email, password, name}
R->>S: signup(payload)
S->>U: hash(password)
U-->>S: bcrypt hash
S->>DB: INSERT users (email, hash, ...)
DB-->>S: user row
S->>J: encode({sub: user.id}, ttl=ACCESS_TTL)
S->>J: encode({sub: user.id, refresh: true}, ttl=REFRESH_TTL)
S-->>R: {user, access, refresh}
R-->>C: 201 Created
SDK touchpoints:
- Public endpoint —
auth.routerdoesn't useDepends(get_current_user). PasswordUtils.hash(bcrypt) +JWTUtils.encode(HS256).- Duplicate-email failure MUST become
ConflictException→ the SDK handler responds with409and the standard envelope.
2. Member invitation¶
sequenceDiagram
autonumber
actor A as Admin (OWNER/ADMIN)
actor I as Invitee
participant R as invitations.router
participant S as InvitationService
participant T as generate_opaque_token
participant E as EmailUtils
participant Q as TaskIQ (async email)
participant DB as Postgres
A->>R: POST /organizations/{id}/invitations {email, role}
R->>S: invite(org_id, payload, current_user)
S->>S: assert role ≠ OWNER
S->>S: assert org_member_count < 10
S->>T: generate_opaque_token(48)
T-->>S: (plain, hash)
S->>DB: INSERT invitations (token_hash, expires_at=now+7d, PENDING)
S->>Q: enqueue send_invitation_email(invite.id, plain)
Q-->>E: render_template("invitation.html", {...})
E-->>I: email with ?token={plain}
S-->>R: invitation
R-->>A: 201
Note over I: 1 day later
I->>R: POST /invitations/{plain}/accept (with invitee JWT)
R->>S: accept(plain, current_user)
S->>S: hash_opaque_token(plain) -> lookup
S->>S: assert invite.email == current_user.email
S->>S: assert not expired & status=PENDING
S->>S: assert org_member_count < 10
S->>DB: BEGIN
S->>DB: INSERT memberships (role=invite.role)
S->>DB: UPDATE invitations SET status=ACCEPTED
S->>DB: COMMIT
S-->>R: membership
R-->>I: 200
SDK touchpoints:
generate_opaque_token(48)returns(plain, hash). Database stores only the hash.EmailUtils.render_template("invitation.html", ctx)(v0.24+).- Send is async (TaskIQ) — endpoint returns
201without waiting on SMTP. - The acceptance is one single transaction — membership + invitation status are atomic.
3. Create product + variant + images¶
sequenceDiagram
autonumber
actor M as Member (ADMIN+)
participant R as products.router
participant CT as ProductController
participant PS as ProductService
participant VS as VariantService
participant ST as MinIOUploadStorage
participant DB as Postgres
M->>R: POST /products {title, description, variants:[{sku, attrs, price_cents}]}
R->>CT: create_product(payload, org_id, user_id)
CT->>PS: create(org_id, payload)
PS->>DB: BEGIN
PS->>DB: INSERT products (...)
loop for each variant
PS->>VS: create_variant(product_id, variant_payload)
VS->>DB: INSERT product_variants (...)
VS->>DB: INSERT price_history (valid_from=now())
end
PS->>DB: COMMIT
PS-->>R: product
Note over M,ST: Image upload (separate step)
M->>R: POST /products/{id}/images/presign
R->>ST: presigned_put_url("products/{id}/{uuid}.jpg", 15min)
ST-->>R: {key, url}
R-->>M: {key, url}
M->>ST: PUT bytes directly to MinIO via presigned URL
M->>R: PATCH /products/{id} {image_keys: [...keys]}
R->>PS: attach_images(product_id, keys)
PS->>DB: UPDATE products SET image_keys = ...
PS-->>R: product
R-->>M: 200
SDK touchpoints:
- Product creation is a single transaction — product + variants + first
PriceHistoryrow. - Images never flow through the API — the client
PUTs directly to MinIO via a presigned URL (MinIOUploadStorage.presigned_urlorAsyncMinIOClient.presigned_put_urldirectly). - The public catalog reads
image_keysand mints presigned read URLs (1h TTL).
4. Idempotent checkout¶
sequenceDiagram
autonumber
actor B as Buyer
participant MW as IdempotencyMiddleware
participant R as orders.router
participant OC as OrderController
participant OS as OrderService
participant SS as StockService
participant SSE as orders/{id}/events stream
participant DB as Postgres
participant Q as TaskIQ
B->>MW: POST /orders {cart_id, address}<br/>Idempotency-Key: chk_uuid
MW->>MW: cache lookup (method+path+key)
alt cache hit
MW-->>B: cached response (200/201)
else cache miss
MW->>R: forward
R->>OC: checkout(cart_id, address, user)
OC->>OS: create_order(cart, address, user)
OS->>DB: BEGIN
OS->>DB: SELECT cart FOR UPDATE
OS->>OS: assert cart.user == user & status=OPEN
OS->>SS: reserve(items)
loop for each item
SS->>DB: assert balance(variant) >= qty
SS->>DB: INSERT stock_movements (kind=RESERVATION)
end
OS->>DB: INSERT orders (status=PENDING, idem_key)
OS->>DB: INSERT order_items (...)
OS->>DB: UPDATE carts SET status=CONVERTED
OS->>DB: COMMIT
OS->>Q: enqueue notify_seller(order.id)
OS->>SSE: publish {order_id, status: PENDING}
OS-->>R: order
R-->>MW: 201 (full body)
MW->>MW: store response under key
MW-->>B: 201 Created
end
SDK touchpoints:
IdempotencyMiddlewarecovers the endpoint without the handler having to care. If the buyer retries with the sameIdempotency-Key, the middleware replays the original response — the handler does not run twice, stock is not decremented twice.- Stock reservation lives inside the same transaction as the order
INSERT. A failure on any item rolls everything back. - The
SSEnotifies the stream (the buyer's client listening on/orders/{id}/events). notify_selleris queued — does not block the checkout response.
5. Shipping + real-time updates¶
sequenceDiagram
autonumber
actor A as Admin (seller)
actor B as Buyer
participant R as orders.router
participant OS as OrderService
participant SS as StockService
participant SSE as orders/{id}/events
participant DB as Postgres
B->>SSE: GET /orders/{id}/events<br/>Accept: text/event-stream
SSE-->>B: event: status (PAID)
Note over A: seller ships
A->>R: POST /orders/{id}/ship {tracking}
R->>OS: transition(order_id, SHIPPED)
OS->>OS: assert current == PAID
OS->>DB: UPDATE orders SET status=SHIPPED
OS->>SSE: publish {status: SHIPPED, tracking}
SSE-->>B: event: status (SHIPPED)
Note over A: buyer confirms delivery
B->>R: POST /orders/{id}/confirm-delivery
R->>OS: transition(order_id, DELIVERED)
OS->>OS: assert current == SHIPPED
OS->>SS: convert_reservation_to_out(items)
SS->>DB: INSERT stock_movements (kind=OUT) per item
OS->>DB: UPDATE orders SET status=DELIVERED
OS->>SSE: publish {status: DELIVERED}
SSE-->>B: event: status (DELIVERED)
SDK touchpoints:
EventStreamkeeps a broadcaster perorder_id— every connected buyer client receives the SSE.- Transition MUST validate the source state (state machine inside the service).
- Stock becomes a definitive
OUTonly on delivery — cancelling earlier turns theRESERVATIONinto aRELEASE.
State machine — Order¶
stateDiagram-v2
[*] --> PENDING : checkout
PENDING --> PAID : payment confirmed (admin mock)
PENDING --> CANCELLED : buyer/admin cancels
PAID --> SHIPPED : seller ships
PAID --> CANCELLED : refund pre-ship
SHIPPED --> DELIVERED : buyer confirms
SHIPPED --> RETURNED : return flow
DELIVERED --> [*]
CANCELLED --> [*]
RETURNED --> [*]
Forbidden transitions (any other arrow) MUST fail with ConflictException("invalid state transition"). Typical implementation is an enum + dict[from, set[to]] in the service.
State machine — Invitation¶
stateDiagram-v2
[*] --> PENDING : invited
PENDING --> ACCEPTED : invitee accepts
PENDING --> REVOKED : admin revokes
PENDING --> EXPIRED : job 7d
PENDING --> SUPERSEDED : new invite for same email
ACCEPTED --> [*]
REVOKED --> [*]
EXPIRED --> [*]
SUPERSEDED --> [*]
EXPIRED is set by a TaskIQ task running hourly that sweeps invitations with expires_at < now().
Next step¶
Jump to the Endpoint map to see the full REST API ready to wire up the frontend contract.