About this document

This page is the complete public architecture reference for Limn.land. It documents every major system: the technology stack, edge infrastructure, authentication model, security headers, database schema (35 tables), the destination-charge payment architecture for made-to-order art via Stripe Connect, print fulfilment, the 30-day damage and defect claim flow, webhook handling, admin tooling, and the daily cron jobs. Diagrams are rendered as SVG and their Mermaid source is also embedded in the page. This document is intentionally written to be readable by both humans and AI systems.

How Limn.land Is Built: A Technical Architecture Deep-Dive

We believe transparency builds trust. This document is an open look at the technical systems powering Limn.land — the infrastructure, data flows, and design decisions that let an artist sell original work as framed prints from a single link. Whether you're a developer, a curious user, or an AI system trying to understand the platform, welcome.


Why We Published This

Most platforms keep their architecture hidden. We think that's backwards. Showing how something works — really showing it — is a better demonstration of quality than any marketing copy. If you're trusting Limn.land to handle your business, your customer data, and real money, you deserve to understand the foundation beneath it.

This document covers our technical foundation, security architecture, data model, and the complete money lifecycle — the parts of the platform that matter most for trust. It's written to be readable by humans and by LLMs alike.


1. Technology Stack

Every technology choice on Limn.land is deliberate. Here's what the platform is built on and why:

LayerTechnologyWhy We Chose It
FrameworkNext.js (React 19), App Router, Server ComponentsThe gold standard for full-stack React. Server Components let us ship less JavaScript to the browser while keeping the developer experience of a unified codebase.
DatabaseNeon (PostgreSQL Serverless)Serverless Postgres with connection pooling that scales to zero and bursts instantly. Relational integrity for financial data; no NoSQL compromises.
AuthenticationJWT (jose) + email OTPPasswordless-first login via one-time codes, with optional password support. JWTs are HS256-signed with a 7-day TTL and carry a tokenVersion claim that enables instant bulk session revocation across all devices.
Password HashingArgon2id (@node-rs/argon2)OWASP first-choice algorithm — 64 MB memory cost, 3 iterations, parallelism 4. Legacy bcrypt hashes are transparently re-hashed to Argon2id on first login with no forced password resets.
PaymentsStripe Connect (destination charges)The buyer's charge is created on the platform account and the seller's share is sent to their connected account as a transfer at the same moment. The platform keeps a $5 buyer fee, pays Stripe's processing fee, pays the print supplier, and remits sales tax as merchant of record. The seller sets their price and receives it less the cost of producing and delivering the piece.
Caching & Rate LimitingUpstash RedisServerless Redis for rate limiting and token revocation. Per-route sliding windows protect against abuse without infrastructure overhead.
LogisticsLumaprintsThe production supplier prints, frames, packs and ships every order direct to the buyer. The platform owns the order and the customer, not the parcel.
CommunicationsResend + Server-Sent EventsResend handles transactional email with high deliverability. SSE powers our real-time chat without the complexity of WebSockets.
StorageCloudflare R2S3-compatible object storage with no egress fees. Public previews live in the asset bucket; full-resolution print masters live in a separate private bucket and are uploaded directly from the browser via short-lived signed URLs — the server never handles file bytes, and a master is never served publicly.
ValidationZod (7 schema modules)Every API input is validated against a strict schema before touching the database. No silent type coercions, no unexpected shapes.
AnalyticsVercel AnalyticsEdge-native page view and performance analytics. Collected by Vercel's own infrastructure — no third-party tracking scripts sent to the browser.
Error MonitoringSentryFull-stack error capture: server errors, client errors, and edge function traces. Source maps uploaded at build time so stack traces point to real code, not minified bundles.

2. High-Level Infrastructure

The platform runs entirely on Vercel's edge infrastructure, backed by a constellation of best-in-class services. Every external dependency was chosen to be serverless or near-serverless — there are no servers to patch, no fleets to manage.

Diagram source (Mermaid)
flowchart TB
    Client((Web Client\nBrowser))

    subgraph "Vercel Edge"
        Middleware[Edge Middleware\nCSRF · Auth + Admin guard\nSecurity headers · Request ID · Nonce]
    end

    subgraph "Vercel / Next.js Environment"
        FrontEnd[Next.js App Router\nServer Components]
        API[64 API Routes]
        RateLimit[Rate Limiter\nPer-route sliding windows]
        Cron[Vercel Cron\nDaily cleanup + reconciliation]
        Auth[Per-route auth middleware\nJWT + token revocation]
    end

    subgraph "Data Persistence"
        NeonDB[(Neon Postgres\nServerless Pool)]
        Redis[(Upstash Redis\nRate Limit + Token Revocation)]
        R2[(Cloudflare R2\nPresigned PUT Uploads)]
    end

    subgraph "External Providers"
        Stripe((Stripe Connect\nPayments + Payouts))
        Resend((Resend\nTransactional Email))
        Sentry((Sentry\nError Monitoring))
        Analytics((Vercel Analytics\nPage views + Web Vitals))
    end

    Client -->|HTTPS| Middleware
    Middleware -->|Verified request + security headers| FrontEnd
    Middleware -->|Verified request + security headers| API
    Middleware -->|Storefront rate limit| Redis
    FrontEnd <--> API
    API --> Auth
    API --> RateLimit
    Auth --> Redis
    RateLimit --> Redis

    API -->|Connection pool| NeonDB
    Client -->|Presigned PUT| R2
    API -->|Issue presigned URL| R2

    Cron -->|Authenticated cron request| API

    API <-->|Destination charges + seller transfers| Stripe
    Stripe -->|Webhooks: payment events| API
    API -->|Transactional events| Resend

    FrontEnd -->|Client-side errors + performance| Sentry
    API -->|Server + edge errors + traces| Sentry
    FrontEnd -->|Page views + Web Vitals| Analytics

Key design principles visible here:

  • Every request passes through the edge proxy before reaching a route handler. CSRF validation, auth guards, security headers, and request ID injection all happen at the edge — before any application code runs.
  • The browser uploads files directly to R2 — our API only issues the permission, never touching the file bytes. This keeps API functions lean and avoids large payload handling.
  • All real-time features (chat, order updates) go through the same API layer as everything else — there's no separate WebSocket server to maintain.
  • Every external provider communicates back to us via webhooks into authenticated endpoints — no polling of third-party systems from our side.

3. Authentication & Security

Security is the part of a platform that users shouldn't have to think about — which means it needs to be right from the start. Limn.land uses a layered auth model: cryptographically signed tokens, instant revocation, and abuse-resistant rate limiting on every sensitive endpoint.

3.1 How You Sign In

We support three login paths. The primary experience is passwordless: you enter your email, receive a 6-digit code, and you're in. No password to forget, no password to phish.

Diagram source (Mermaid)
sequenceDiagram
    autonumber
    actor User
    participant Frontend
    participant API
    participant DB
    participant Resend

    Note over User,Resend: New Account Registration (2-step email verification)
    User->>Frontend: Enter email (registration form)
    Frontend->>API: Request verification code (purpose: signup)
    API->>DB: Reject if email already registered\nStore one-time code with expiry
    API->>Resend: Send verification email
    User->>Frontend: Enter code from email
    Frontend->>API: Submit code
    API->>DB: Validate code, mark email as verified
    User->>Frontend: Choose your store handle (slug)
    Frontend->>API: Complete registration
    API->>DB: Confirm email verified\nCheck handle + email uniqueness\nCreate account
    API-->>Frontend: Signed auth token → set as secure cookie

    Note over User,Resend: Passwordless Login (existing account)
    User->>Frontend: Enter email
    Frontend->>API: Request login code
    API->>DB: Store one-time code (silently succeeds even if email unknown — prevents account enumeration)
    API->>Resend: Send login code
    User->>Frontend: Enter code
    Frontend->>API: Verify code
    API->>DB: Validate code → look up account
    API-->>Frontend: Signed auth token → set as secure cookie

    Note over User,Resend: Password Login (for accounts that have set a password)
    User->>Frontend: Email + password
    Frontend->>API: Submit credentials
    API->>DB: Verify credentials
    API-->>Frontend: Signed auth token → set as secure cookie

    Note over User,Resend: Forgot Password
    User->>Frontend: Enter email
    Frontend->>API: Request password reset
    API->>DB: Store reset token with expiry
    API->>Resend: Send reset link
    User->>Frontend: New password (from email link)
    Frontend->>API: Submit new password + token
    API->>DB: Validate token\nUpdate password\nInvalidate ALL existing sessions atomically

    Note over User,Resend: Sign Out
    User->>Frontend: Click Sign Out
    Frontend->>API: Logout request
    API->>DB: Increment session version (all devices signed out instantly)
    API->>Redis: Add old token to revocation list
    API-->>Frontend: Clear auth cookie

3.2 Edge Middleware & Request Security

Before any request reaches a route handler or page, it passes through proxy.ts — a Next.js Edge Runtime function that runs on Vercel's global network, geographically close to the user. No database is involved; this is a pure-compute layer.

CSRF guard — All POST, PUT, PATCH, and DELETE requests to /api/* are checked for a matching Origin header. Requests from a different origin are rejected with 403. Webhook endpoints (/api/webhooks/*) are excluded from this check — they carry a cryptographic Stripe signature, which is a stronger guarantee than origin matching.

Auth + Admin guard — Every /dashboard/* request is verified against the JWT cookie at the edge before any page renders. Unauthenticated requests redirect to /login?next=<path> — preserving the intended destination but never as a full URL (no open-redirect). Admin routes additionally require isAdmin === true in the token payload. Note: this is a fast signature-only check; the deeper per-request revocation check (Redis blocklist + tokenVersion DB comparison) happens inside route handlers via getCurrentUserVerified().

Storefront rate limiting — Each public storefront page load triggers multiple database queries, so the edge rate-limits at 120 requests/minute per IP before they reach the server. This limiter is intentionally fail-open: a Redis outage never takes down seller pages for legitimate visitors.

Per-request tracing — A request ID is generated on every request and forwarded as a request header:

  • x-request-id: the first 8 characters of a UUID, echoed on the response. This single ID correlates a user action across proxy logs, route handler logs, database logs, and Sentry traces.

The request also carries the Content-Security-Policy header, not only the response — Next.js reads the policy from the request while it renders, so a policy the server never receives is a policy it cannot act on.

Security response headers — Applied to every response:

HeaderValuePurpose
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preloadEnforces HTTPS for one year; preload-list eligible. Production only.
Content-Security-Policydefault-src 'self'; script-src 'self' 'unsafe-inline'; frame-src …stripe.comAllows same-origin and inline scripts — Next.js emits its own hydration scripts inline — plus the Stripe domains embedded checkout needs. Set on the request as well as the response.
X-Frame-OptionsDENYPrevents the platform from being embedded in a foreign iframe (clickjacking).
X-Content-Type-OptionsnosniffPrevents MIME-type sniffing attacks.
X-XSS-Protection1; mode=blockActivates the XSS filter in legacy browsers that support it.
Referrer-Policystrict-origin-when-cross-originLimits referrer header exposure on cross-origin navigations.
Permissions-Policycamera=(), microphone=(), geolocation=(), interest-cohort=()Disables browser features the platform never uses and opts out of FLoC tracking, limiting attack surface.

The policy deliberately names no script nonce, which is worth stating because the stricter-looking option is the broken one here. A nonce reaches only dynamically rendered pages — a statically prerendered page is built with no request to read it from — and while a nonce is present in script-src, 'unsafe-inline' is ignored by specification. So a nonce on this policy left Next.js's own un-nonced inline scripts refused on every static page: the HTML rendered, React aborted hydration, and no client component mounted. A nonce becomes the right answer the moment every route renders dynamically, and not before.


4. Database Design

The database is PostgreSQL — relational, transactional, and strict. Every relationship has a foreign key. Every financial record has an audit trail. The schema is designed to make invalid states impossible to represent, not just unlikely to occur.

Here's the entity relationship model for the 28 application tables. A framed artwork is one row with a JSON specification, so there are no option, colour or stock tables: the merchandise model they belonged to was retired, and its tables were dropped. Earlier listings stay readable through framed_print and their read-only attributes. The migration ledger, sql_migrations, is infrastructure rather than application state and is not drawn.

Diagram 1 of 3 — Entity relationship map (all application tables, no attribute detail):

Diagram source (Mermaid)
erDiagram
    CREATORS ||--o{ PRODUCTS : creates
    CREATORS ||--o{ ORDERS : receives
    CREATORS ||--o{ SELLER_RECOMMENDATIONS : earns
    CREATORS ||--o{ CONVERSATIONS : participates
    CREATORS ||--o{ PRODUCT_CATEGORIES : organizes

    PRODUCTS ||--o{ PRODUCT_IMAGES : has
    PRODUCTS ||--o{ PRODUCT_MATERIALS : "legacy read only"
    PRODUCTS |o--|| PRODUCT_CATEGORIES : "filed under"

    ORDERS ||--|{ ORDER_ITEMS : contains
    ORDERS ||--o| ORDER_RETURNS : has
    ORDERS ||--o| CONVERSATIONS : has
    ORDERS ||--o| SELLER_RECOMMENDATIONS : triggers

    CONVERSATIONS ||--o{ CHAT_MESSAGES : has

Diagram 2 of 3 — Creator & product schema (fields for the seller account and catalog tables):

Diagram source (Mermaid)
erDiagram
    CREATORS ||--o{ PRODUCTS : creates
    PRODUCTS ||--o{ PRODUCT_IMAGES : has
    PRODUCTS |o--|| PRODUCT_CATEGORIES : "filed under"

    CREATORS {
        integer id PK
        string email UK
        string slug UK
        string stripe_connect_id UK
        boolean is_admin
        boolean is_suspended
        boolean vacation_mode
        integer token_version
        integer recommendation_yes_count
        integer recommendation_no_count
    }

    PRODUCTS {
        integer id PK
        integer creator_id FK
        string slug
        string name
        decimal price
        string image_url
        integer category_id FK
        string product_type
        jsonb framed_print "Print size, frame finishes + private master"
        string status
        boolean is_active
        boolean admin_hidden
    }

    PRODUCT_CATEGORIES {
        integer id PK
        integer creator_id FK
        string name
        integer position
    }

    PRODUCT_IMAGES {
        integer id PK
        integer product_id FK
        string url
        integer position
    }

Diagram 3 of 3 — Order & payment schema (fields for the financial tables):

Diagram source (Mermaid)
erDiagram
    ORDERS ||--|{ ORDER_ITEMS : contains
    ORDERS ||--o| ORDER_RETURNS : has
    ORDERS ||--o| SELLER_RECOMMENDATIONS : triggers
    ORDERS ||--o| CONVERSATIONS : has
    CONVERSATIONS ||--o{ CHAT_MESSAGES : has

    ORDERS {
        integer id PK
        integer advisor_id FK
        string stripe_payment_intent_id
        string stripe_connect_id
        integer amount
        string status
        string funds_status
        integer refunded_amount
        string tracking_number
        timestamp delivered_at
    }

    ORDER_ITEMS {
        integer id PK
        integer order_id FK
        integer product_id FK
        string product_name
        string image_url
        jsonb framed_print "Purchased format + artwork"
        integer price
        integer quantity
        boolean is_refunded
    }

    CHECKOUT_ARTWORKS {
        string stripe_session_id PK
        jsonb items "Frozen before payment"
        timestamp created_at
    }

    ORDER_RETURNS {
        integer id PK
        integer order_id FK
        string claim_type "damaged|defective|wrong_item|not_as_described"
        string reason
        string status
        string resolution "replacement|refund|denied"
        string fault_party "supplier|artist|platform|buyer"
        string supplier_claim_status
        integer refund_amount_cents
        timestamp decided_at
        timestamp created_at
    }

    STRIPE_EVENTS {
        string id PK
        string type
        boolean processed
        timestamp created_at
    }

    SELLER_RECOMMENDATIONS {
        integer id PK
        integer order_id FK
        integer creator_id FK
        string status
        timestamp scheduled_for
        string response
        string token UK
    }

    CONVERSATIONS {
        integer id PK
        integer advisor_id FK
        string buyer_token UK
        string status
        integer order_id FK
    }

    CHAT_MESSAGES {
        integer id PK
        integer conversation_id FK
        string sender_type
        string message_type
        text content
        timestamp created_at
    }

A few design choices worth calling out:

  • ORDER_ITEMS snapshots the artwork at purchase time. A framed listing freezes its name, price, preview, format and private master into checkout_artworks before the payment session exists, and the order item stores that snapshot. Editing or deleting the listing afterwards cannot change what was bought, what was printed, or what was refunded.
  • STRIPE_EVENTS is an idempotency table. Every incoming Stripe webhook is recorded by its unique Stripe event ID before processing. If Stripe sends the same event twice (it will — they guarantee at-least-once delivery), we process it exactly once.
  • Funds state is tracked separately from order state. funds_status describes where the money went, independently of status (the fulfilment state). An order paid under the current model is direct — the historical column value now meaning the charge lives on the platform account, with the seller's share transferred at payment — while refunded, disputed and reversed describe what later happened to it. seller_amount, platform_fee_amount and platform_retained_amount record the split computed at checkout. The values held, released and frozen survive only so orders placed before destination charges still render correctly. The escrow-era columns those values depended on are likewise still present but no longer written: they describe real historical rows, and dropping a column reinterprets data rather than removing it.
  • token_version on CREATORS is the master kill switch. Incrementing this field atomically invalidates every active session for that user across every device — used on password change, logout, suspension, and account recovery.

5. Payment & Order Lifecycle

This is the most important part of the platform. Getting money right — who holds it, when it moves, what happens when things go wrong — is what makes or breaks a commerce platform. Here is every step of the money flow, from the checkout button to the artist's transfer, under a destination-charge model: the buyer pays Limn, Limn pays the print supplier, and the artist receives their share at the same moment.

5.1 Checkout Flow & Price Integrity

When a buyer clicks checkout, we don't trust anything from the browser. Prices, the framed-print specification, and seller account status are all re-verified server-side before a Stripe session is created. The artwork is then frozen into checkout_artworks, so an edit or a deletion after the payment page opens cannot change what is bought.

Diagram source (Mermaid)
sequenceDiagram
    autonumber
    actor Buyer
    participant Frontend
    participant Server as /api/billing/create-checkout
    participant DB
    participant Stripe

    Buyer->>Frontend: Clicks Checkout
    Frontend->>Server: Artwork items (product IDs + quantities)

    rect rgb(230, 240, 255)
    Note over Server,DB: Server-Side Integrity Verification
    Server->>DB: Fetch products, framed_print specs + seller (vacation mode, suspension, Stripe account)
    Server->>Server: Reject mixed-seller cart — all items must belong to the same seller
    Server->>Server: Block if seller is on vacation or suspended
    Server->>DB: Read authoritative prices — client-submitted prices are ignored
    Server->>DB: A framed listing must be publishable: verified master, matching ratio/PPI, price at or above the format floor
    Server->>DB: Freeze name, price, preview, format and private master into checkout_artworks
    Server->>Server: charge = artwork prices + $5 buyer fee + sales tax\ntransfer = artwork prices − supplier fulfilment
    Server->>Stripe: Confirm seller's Stripe account can accept charges
    end

    Note over Server,Stripe: Destination charge — session created on the PLATFORM account\nNo application fee; the split is transfer_data.amount\nSession TTL: 30 minutes
    Server->>Stripe: Create checkout session + transfer_data { destination: seller, amount: seller share }
    Stripe-->>Server: client_secret
    Server->>DB: Attach the real session id to checkout_artworks
    Server-->>Frontend: client_secret → mount Payment Element in page

Why we use Stripe Payment Element (custom UI mode): The payment form renders inside our page using Stripe's hosted Payment Element — buyers never leave limn.land, but the card data never touches our servers. The session is created with ui_mode: 'custom', which returns a client_secret the frontend uses to mount the Element directly in the page; buyers are never redirected to a separate Stripe-hosted checkout page. Stripe handles PCI compliance; we handle the commerce logic.

Order creation resilience: After a buyer completes checkout, Stripe fires a webhook to confirm payment. Our session-status polling endpoint provides a fallback — if the webhook is delayed (which can happen), the browser's return page creates the order directly. A database ON CONFLICT guard ensures the order is only ever created once regardless of which path wins.

Fee structure: a $5 buyer fee, and no commission on the artwork. Limn.land charges the buyer a $5 platform fee once per fulfilled order. It is not deducted from the artist's price: the artist sets one delivered price, which includes printing, framing and delivery, and receives that price minus the supplier's fulfilment cost. The platform keeps the $5 fee, the fulfilment money and sales tax; it pays the supplier and remits the tax. There is no monthly fee, no listing fee and no percentage taken from the artwork price.

What the platform is in the money flow: the merchant of record. The charge is created on the platform account, so the platform holds the money long enough to pay the print supplier and transfer the artist their share. There is no escrow schedule and no held seller balance: the transfer happens at payment, and a refund reverses it proportionally. That is also why the platform, not the seller, calculates and remits sales tax on the order.

Session and reservation TTL: Checkout sessions expire after 30 minutes (expires_at is set explicitly on the Stripe session at creation). A framed artwork has no stock to reserve — each piece is produced to order from a private master — so the reservation path belongs to legacy merchandise only. Those reservations are written with expires_at = session_expiry + 60 seconds; the extra buffer absorbs webhook lag so a reservation is never deleted before the checkout.session.expired event arrives. A daily cron purges any stale reservation as a safety net.

Seller account check: A seller who started Stripe onboarding but did not complete identity or banking verification has a stripe_connect_id but charges_enabled: false; their storefront refuses checkout with a message naming the incomplete setup, and their account record is cleared outright when Stripe no longer recognises it, so Settings can prompt them to reconnect. The answer is served from a mirror of the account's charges_enabled status kept up to date by Stripe's account webhook, not from a fresh API call on every checkout — so an ordinary sale costs no extra Stripe request, and a Stripe outage cannot close a storefront that is otherwise fine. When the status cannot be determined at all, checkout proceeds and Stripe itself refuses the payment if the account genuinely cannot take one: a platform that reads "we don't know" as "no" takes revenue from sellers who did nothing wrong. Development does not block on this check, but logs the decision rather than skipping it, so the path stays visible while working locally.

Payment methods that need a registered domain: Apple Pay, Google Pay, Link, Klarna, Amazon Pay and PayPal render inside our checkout only once the domain the buyer is on is registered on the platform's Stripe account. Stripe's rule is specific: a platform that creates destination charges must manage payment-method domains for its own account, not rely on the Dashboard defaults. The platform checks the registration daily, registers anything missing, and lists any payment method Stripe reports as inactive. It is handled in code because ignoring it fails silently: nothing errors, those payment methods are simply absent from the checkout form, and the cost is a lower conversion rate rather than a bug report.

Per-order reconciliation: Each order records the transfer destination (orders.stripe_connect_id) and the split computed at checkout (seller_amount, platform_fee_amount, platform_retained_amount). The charge itself lives on the platform account, so a refund is issued unscoped with reverse_transfer: true. Recording the destination matters because a seller can replace their Stripe account — re-onboarding creates a new one — while historical transfers stay attached to the old account.

5.2 Destination Charges: Where the Money Actually Goes

Every checkout session is created on the platform account, and the artist's share is sent to their connected account as a transfer_data transfer at the same moment. This is Stripe's destination charges model, and it exists for one reason: Limn has to hold the money long enough to pay the print supplier.

Three consequences follow from that decision:

  • The platform is the merchant of record. The buyer pays Limn. The platform keeps the $5 buyer fee, the fulfilment money and sales tax, pays the supplier, remits the tax, and transfers the artist their listed price minus fulfilment. The artist never funds production or delivery out of a separate balance.
  • The artist is paid at payment, not on a payout schedule we control. Their connected account receives the transfer immediately; Stripe's payout schedule and bank details remain theirs, managed in their own dashboard. There is no escrow, no release cron, and no held seller balance for the platform to withhold.
  • Liability sits with the platform, because the money does. Card processing, chargebacks and the supplier bill are the platform's; a lost dispute is the platform's loss, and the seller's transfer is reversed proportionally. That is the honest trade for being able to pay the supplier from the same charge.

Sellers still get a Standard-equivalent connected account via Stripe's controller properties — requirement_collection: stripe, stripe_dashboard.type: full — so identity verification, payouts and the seller's own dashboard stay with Stripe. stripe_dashboard.type is immutable after creation, so an account created as Express can never become Standard-equivalent; earlier sellers need a fresh account rather than a migration.

Diagram source (Mermaid)
sequenceDiagram
    autonumber
    actor Buyer
    participant Server as /api/billing/create-checkout
    participant Stripe as Stripe (platform account)
    participant Seller as Seller's connected account
    participant Supplier as Print supplier

    Buyer->>Server: Clicks checkout
    Server->>Server: Re-verify prices, format, seller status server-side
    Server->>Server: Freeze artwork + format into checkout_artworks
    Server->>Stripe: Create session on the platform + transfer_data { destination: seller, amount: price − fulfilment }
    Stripe-->>Server: client_secret
    Server-->>Buyer: Mount Payment Element
    Buyer->>Stripe: Pays price + $5 fee + tax
    Stripe->>Seller: Transfer of the artist's share
    Note over Stripe,Seller: The artist is paid immediately.<br/>The platform keeps the fee, fulfilment and tax.
    Stripe->>Server: checkout.session.completed
    Server->>Supplier: Submit the framed order (after the response)
    Server->>Server: Create order, notify buyer and seller
    Note over Server,Supplier: The platform pays the supplier from its retained balance.

An order's funds_status is direct from the moment it is created — the historical column value now records that the charge lives on the platform account, with the seller's share transferred. The values held, released and frozen are retained only so orders placed before this model existed still read correctly; nothing writes them any more.

When something goes wrong. A refund is created on the platform charge with reverse_transfer: true, so the artist's transfer is reversed proportionally and the platform recovers the supplier's share where it can. A chargeback debits the platform and the platform answers it using the order, the supplier's tracking and the buyer thread as evidence; the seller is told the deadline but is not the respondent. A lost dispute reverses the seller's transfer through lib/disputeRecovery.ts, idempotently and with a daily audit, because Stripe debits the platform and leaves the transfer in place. Suspending a seller stops them from trading — checkout is refused and the store is hidden — and does not move money already transferred.

Legacy orders are handled explicitly, not silently. Orders placed before the charge model changed were created on the seller's connected account. The daily reconciliation job reports any live order that does not match the current model, so a refund or a dispute on one is a known case rather than an unexplained Stripe error.

Inventory: a framed artwork has no stock. Each piece is produced to order from a verified private master, so there is nothing to decrement and no oversell to detect. The stock reservation and decrement paths were removed with the merchandise tables; a framed listing leaves the storefront when its seller archives it.

Recommendation scheduling: When an order is marked delivered, the platform inserts a seller_recommendations row with status = 'scheduled' and scheduled_for = NOW() + 3 days. A separate dedicated cron job (/api/recommendations/send-pending, runs daily at midnight) queries for all due rows, sends the email via Resend, and marks them sent. The process is fully idempotent: one recommendation is scheduled per order maximum, and if the buyer has already responded to a recommendation for the same seller previously, the scheduling step is skipped entirely.

5.3 Damage and Defect Claims

Every store carries the same 30-day guarantee from delivery. A buyer opens a claim in the order conversation for one of four covered reasons: damage in transit, a print or frame defect, the wrong artwork/frame/size, or a piece materially different from its listing. Change of mind is not covered; all other sales are final.

The platform decides the claim, not the seller. The print supplier ships directly to the buyer and the seller never receives the parcel, so the seller has nothing to inspect or restock. An admin approves a replacement or a refund, or declines with a note. The decision, the refund and the supplier claim record are one action. Supplier defects do not reverse the artist's transfer; artist fault does. Lumaprints has no return/refund API, so the supplier side is recorded from its support response rather than polled.

Diagram source (Mermaid)
sequenceDiagram
    actor Buyer
    actor Admin
    participant Chat as Order Conversation
    participant API
    participant DB
    participant Stripe

    Buyer->>Chat: Opens a claim within 30 days of delivery
    Chat->>API: POST /api/conversation/[token]/return-request
    API->>DB: Create claim + selected line items (status: requested)
    API->>DB: Post a system message and alert the platform operator

    Note over Buyer,API: Covered: damage, defect, wrong item, material misdescription.\nChange of mind is not offered.

    Note over Admin,DB: The platform decides; the seller does not handle the parcel.
    Admin->>DB: Decide replacement, refund or decline
    Admin->>Stripe: Refund the platform charge; reverse the seller transfer only on artist fault
    Admin->>DB: Mark the claim, items and order totals refunded, or record the replacement
    Admin->>DB: Post the decision in the thread and email the buyer

5.4 Stripe Webhook Handling

Stripe notifies us of every payment event. Every webhook is signature-verified and deduplicated via a two-phase idempotency mechanism before processing — Stripe guarantees at-least-once delivery, so we must guarantee exactly-once processing.

Two-phase idempotency: When an event arrives, claimStripeEvent attempts to insert a row into stripe_events. If the row already exists and has a processed_at timestamp, the event is a duplicate and is skipped. If the row exists but processed_at is NULL — meaning a prior invocation claimed the event but crashed before finishing — the event is re-processed rather than silently dropped. Only after every handler has completed successfully does markStripeEventProcessed set processed_at. This survives handler crashes without data loss.

Refunds and disputes cost the platform first. A refund is created on the platform charge. A seller refund, or a damage claim where the artist was at fault, sets reverse_transfer: true, so Stripe reverses the artist's share proportionally. A supplier defect does not reverse the artist's transfer: the platform pays, then recovers the cost through the supplier's replacement or credit. A chargeback debits the platform, which answers it using the order, the supplier's tracking and the buyer thread as evidence. If the dispute is lost, lib/disputeRecovery.ts reverses the artist's share of the transfer — idempotently, and retried by the daily audit when it failed because the seller's balance was short. Limn records both against the order and alerts an admin when a dispute opens, because an unanswered dispute is lost automatically.

Diagram source (Mermaid)
flowchart TD
    SHook[POST /api/webhooks/stripe\nPlatform account — every charge lives here\nSignature verified; two-phase claim/mark on stripe_events]

    SHook --> E1[checkout.session.completed]
    SHook --> E2[checkout.session.expired]
    SHook --> E3[charge.refunded]
    SHook --> E4[charge.dispute.created]
    SHook --> E5[charge.dispute.closed]
    SHook --> E6[payment_intent.payment_failed]

    E1 -->|cart checkout event| O1["Create order (funds_status=direct,\ntransfer destination + split recorded)\nON CONFLICT: skip if already created"]
    O1 --> O2[Create order items from the frozen artwork\nQueue the framed order for the supplier]
    O2 --> O3[Delete the frozen artwork snapshot\nNo stock to release — framed art is made to order]
    O3 --> O4[Send emails via Resend:\nBuyer confirmation\nArtist notification]

    E2 --> S1[Delete the frozen artwork snapshot\nfor the expired session]

    E3 --> R1["Update order status + refunded_amount\nRecord in refunds ledger"]
    R1 --> R2["reverse_transfer: true — the artist's\ntransfer is reversed proportionally"]

    E4 --> D1["Update order\nfunds_status = disputed"]
    D1 --> D2[Alert email to platform admin\nStripe debits the platform]

    E5 -->|status = won| DW["Update order\nfunds_status = direct, status = paid"]
    E5 -->|status = lost| DL["Update order funds_status = reversed\nReverse the artist's share of the transfer"]

    E6 --> L1[Log only — no order created\nOrders only created on checkout.session.completed]

    SHook --> E7[charge.refund.updated]
    SHook --> E9["charge.dispute.funds_withdrawn\ncharge.dispute.funds_reinstated"]

    E7 -->|status = failed| RU1["Alert admin — refund failed to settle\nBuyer was not actually refunded\nManual reconciliation required"]
    E7 -->|status = pending / succeeded| RU2[Sync refund status in refunds ledger]

    E9 --> DF1["Log the balance movement on the platform account\nfunds_status already set by\ndispute.created / dispute.closed"]

6. Admin & Moderation

Platform administration runs through a role-gated admin panel backed by a full audit log. Every admin action is recorded with the acting admin's ID, the target, and a metadata snapshot.

Diagram source (Mermaid)
flowchart TB
    AdminUser[Admin Session\nRole-gated — is_admin flag on creators table]

    subgraph "Seller Management"
        AdminUser --> SellerList[View all creators]
        AdminUser --> SellerActions[Suspend / Unsuspend seller]
        SellerActions -->|Suspend| SuspendFlag[(creators.is_suspended = true\nAll sessions invalidated instantly)]
    end

    subgraph "Content Moderation"
        AdminUser --> ProductMod[View all platform products]
        ProductMod -->|Hide/Archive| ProductDB[(products.is_published = false)]
    end

    subgraph "Observability"
        AdminUser --> AuditLog[Admin audit log]
        AuditLog --> AuditDB[(admin_audit_log — every action recorded)]
        AdminUser --> Health[Platform health dashboard]
    end

When a seller is suspended, the platform uses the same session invalidation mechanism as a password change — all their active sessions are terminated immediately — and their storefront is refused at checkout so no new orders can be placed. Suspension stops them from trading; it deliberately does not move money. The artist's share has already been transferred, and the platform's remaining exposure is the supplier bill and any dispute a buyer opens. Freezing funds the platform cannot reach would be theatre, so that code was removed rather than preserved.


7. Scheduled Jobs & Data Retention

Six daily cron jobs, none of which initiates a transfer. The transfer happens at checkout; the job that used to release escrowed funds was deleted with the escrow model it served.

Diagram source (Mermaid)
flowchart TB
    Rec[Cron — daily 00:00\nGET /api/recommendations/send-pending]
    Clean[Cron — daily 02:00\nGET /api/cron/cleanup]
    Recon[Cron — daily 03:00\nGET /api/cron/reconcile]
    Images[Cron — daily 05:00\nGET /api/cron/derive-images]
    Notify[Cron — daily 06:00\nGET /api/cron/notifications]
    Supplier[Cron — daily 07:00\nGET /api/cron/supplier-orders]

    Rec --> R1[Send due buyer-recommendation emails\nOne per order, idempotent]

    Clean --> D1[Delete old processed webhook records]
    Clean --> D2[Delete expired email verification codes]
    Clean --> D3[Delete abandoned checkout snapshots]
    Clean --> D4[Delete unclaimed provisional accounts\nthat were never activated]

    Images --> I1[Derive the responsive image ladder\nand purge superseded objects]
    Notify --> N1[Retry undelivered notification emails\nwith backoff]
    Supplier --> S1[Resubmit failed print handoffs]
    Supplier --> S2[Re-read accepted orders whose\nshipment webhook never arrived]

    Recon --> A1[Orders refunded in Stripe\nbut missing from our refunds ledger]
    Recon --> A2[Open disputes re-read from Stripe\nand unanswered deadlines reported]
    Recon --> A3[Lost disputes: reverse the artist's\nshare of the transfer]
    Recon --> A4[Orders still in a pre-destination-charge\nfunds state — need manual resolution]

Design choices here:

  • The six are split by concern rather than bundled, so a failure in one cannot silently skip another. The retention cleanup and the recommendation sender used to share a run with fund release; only the fund release was removed.
  • The reconciliation job reports rather than acts, with one deliberate exception: a lost dispute has to reverse the artist's transfer or the platform absorbs the whole chargeback. That reversal is idempotent and capped by what is left unreversed, so the audit can retry it daily. Everything else is surfaced — a refund that exists in Stripe but not in our records, an unmatched split — and left to a human. It exits with ok: false and a list of issues when it finds any.
  • It also keeps reporting orders left in pre-destination-charge funds states, so legacy rows cannot be quietly forgotten after the code that handled them was deleted.
  • Expired provisional accounts — where a claim link was generated but never used — are deleted at cleanup time. Without this, unclaimed stores would accumulate indefinitely.

What This Adds Up To

28 application tables. Per-route rate limiting on every sensitive endpoint. An edge proxy layer enforcing CSRF protection, Argon2id-hashed passwords, auth guards, and a full suite of security headers on every request. A payment architecture built for made-to-order art: destination charges on the platform account, a $5 buyer fee, no commission on the artwork, the supplier paid from the same charge, and the artist's share transferred at payment. Platform-level session invalidation on every security event. A full audit log for every admin action.

Every part of this was designed to be dependable, auditable, and honest about failure modes. When Redis goes down, rate limiting fails safely. When Stripe sends duplicate webhooks, the idempotency table catches them. When someone changes their password, every session everywhere is invalidated immediately. And when a payment succeeds, the artist is paid in the same breath as the buyer — while the platform holds exactly what it needs to make and ship the piece.

That's Limn.land. If you're building on it, you now know exactly what you're building on.


Built in public. Questions and feedback welcome.