Event-Driven Architecture
Purpose
Where SellVia relies on events (mostly from Paddle) rather than direct synchronous calls, and why.
Why This Matters Here Specifically
Because SellVia processes real payments via Paddle, a lot of the system's state changes are driven by webhooks, not by the user's own request finishing. A checkout succeeding, a refund happening, a payout completing — these are all things Paddle tells SellVia about asynchronously, and the backend has to react correctly and idempotently.
Key Events
| Paddle webhook | SellVia reaction |
|---|---|
transaction.completed | Mark Sale as verified, credit creator/merchant balances (already split by Paddle), trigger notifications |
charge.refunded | Mark Sale as refunded, apply the 14-day clawback rule (01. Business Logic → Commission Engine) |
payout.paid | Mark Payout as paid, notify the recipient |
payout.failed | Mark Payout as failed, retry per Payout State Machine, alert Admin if repeated |
seller.updated (Connect) | Update a Merchant/Creator's onboarding/KYC status — relevant for gating whether they can receive payouts yet |
Reliability Requirements
- Webhook signature verification on every incoming Paddle webhook — non-negotiable, this is the primary attack surface for someone trying to fake a "sale" or "payout" event (see 04. Security → Webhook Security, not yet written)
- Idempotent processing — Paddle can and will redeliver webhooks; handlers must not double-credit a wallet if the same event arrives twice
- Queue, don't process inline — webhook handlers should enqueue a job and return 200 quickly, then process asynchronously (see Background Jobs), so Paddle doesn't time out and retry unnecessarily
Open Questions
- Whether to build a generic internal event bus (for notification triggers, analytics events, etc. beyond just Paddle webhooks) now, or keep it simple and Paddle-webhook-specific for MVP — recommend keeping it simple until there's a second real event source that justifies the abstraction
Diagram
sequenceDiagram
participant S as Paddle
participant API as API Endpoint
participant Q as Redis Queue
participant W as Worker
S->>API: POST /webhooks/paddle
API->>API: Verify signature
API->>Q: Enqueue job
API-->>S: 200 OK (fast, non-blocking)
Q->>W: Job picked up
W->>W: Process idempotently (update Sale/Payout state)
Update (2026-08-04): The Full Chain, Explicit u2014 SellVia's Actual Version
Restating this as one explicit sequence, translated from generic payment-webhook language into what actually happens here (no subscriptions exist in SellVia u2014 Platform Business Model & Pricing explicitly rejected that model in favor of a flat 2% fee, so this chain replaces "activate subscription" with what SellVia actually does):
On transaction.completed (one verified event, one action, every time u2014 idempotency key prevents any repeat):
- Verify signature first u2014 nothing below runs if this fails (04. Webhook Security, unchanged, non-negotiable)
- Mark the Sale verified (not "invoice paid" u2014 SellVia's equivalent record, 01. State Machines)
- Credit balances u2014 commission and platform fee, already split by Paddle in the same transaction (01. Commission Engine)
- Update tenant-scoped access/state u2014 the Creator's wallet balance, the Merchant's sale count, both tenant-isolated per 04. Tenant Isolation Audit
- Send the notification u2014 "sale made," "commission earned" (01. Notification Logic)
Trust boundary, restated plainly: the button (frontend "pay" click) never triggers any of the above directly u2014 only the verified webhook event does. A user closing their browser right after paying still results in the full chain running, because it's driven by Paddle's event, not by the frontend completing a request. This was already the design (Event-Driven Architecture's whole reason for existing) u2014 restating it here as the explicit trust rule it always was.
Double-charge prevention, restated: Paddle redelivers webhooks by design (not a bug to guard against, an expected behavior to design for) — idempotent processing means a redelivered transaction.completed for the same Paddle transaction ID is a no-op the second time, never a second credit. Already required (Webhook Security, Event-Driven Architecture); this is the same rule, not a new one.
Update (2026-08-23): ⚠️ This Doc Predates Two Major Reversals
Same scope of staleness as System Architecture's 2026-08-23 update — this doc's entire "Key Events" table, diagram, and worked "Full Chain" section describe SellVia-hosted checkout with an instant Paddle split, both reversed 2026-08-07, on top of Paddle itself being replaced by Swich 2026-08-23. A line-patch would misrepresent how confident this correction is, so flagging the scope instead:
transaction.completed→ "already split by Paddle" — wrong twice over: there's no live split (periodic billing since 2026-08-07) and no Paddle (Swich since 2026-08-23). The real event chain is: Shopifyorders/paidwebhook → sale accepted → added to open BillingCycle → (later, on cycle close) Swich billing-payment-confirmed webhook → BillingCycle markedcharged→ creator payouts released → Swich payout-confirmed webhook → Payout markedpaid. Two independent webhook sources (Shopify, Swich), not one (Paddle).charge.refunded/ "14-day clawback rule" — both wrong independent of today's update: there is no clawback at all (01. Commission Engine, reversed 2026-08-07); a refund is a merchant-requested billing credit (05. Refund Handling), not a webhook-driven event.seller.updated— was Paddle Connect KYC/onboarding status; needs a Swich equivalent, unconfirmed.- Diagram's
POST /webhooks/paddle→POST /webhooks/swichand, separately,POST /webhooks/shopify-sales— two endpoints now, not one.
Recommended fix, not done here: redraw the sequence diagram and rewrite the Key Events table against the actual current model (05. Payment Flow, 01. Money Flow, both current as of 2026-08-23) rather than the hosted-checkout model this doc was originally written for.
References