Litestore · Developer guide

Providers

Learn which external services sit behind a swappable interface, which are integrated directly, and which environment variables the app refuses to boot without.

Some external services in Litestore sit behind an interface a second implementation could fill.

Others are integrated directly, because a seam would add indirection without adding a second implementation.

This page names which is which, and what "swappable" actually means in each case. The word does more work in some rows below than in others.

Behind an interface

ConcernWhat ships todayWhere the seam is
PaymentsStripePaymentProvider in types/payments.ts — checkout, refund, webhook verification
TaxFlat per-channel rate, or Stripe TaxTaxProvider in services/tax/provider.tsresolve, with optional commit/reverse
AIGoogle Gemini, Anthropic, OpenAI via the AI SDKAI_PROVIDER selects; lib/ai/model.ts is the one model resolver
NewsletterKlaviyo, Mailchimp, ConvertKit, Buttondown, Loops, Beehiiv, or a plain webhookNewsletterProvider in lib/newsletter/providers.ts
Social postingBluesky, Twitter/XSOCIAL_PLATFORMS in lib/social-media/platforms.ts, one client per platform
Merchant feedsGoogle Merchant, Meta CommerceThe syncHandlers map in services/integrations/platforms/index.ts
ImportsShopify, CSVImportAdapter in lib/import/adapters/types.ts — async generators over one resumable runtime

The pattern is the same everywhere: the adapter moves data, the record stays in your database.

A payment capture lands as a row in your own append-only PaymentTransaction ledger whichever provider moved the money, so swapping a provider never rewrites your reporting.

Two of these are thinner than "swappable" suggests, and it is worth knowing which.

Payments is a branch

getPaymentProvider(name) is a branch, not a registry.

An unknown name throws rather than falling back to Stripe.

Newsletter is an order, not a setting

getNewsletterProvider() returns the first provider whose environment variables are set, in a fixed order:

Klaviyo → Mailchimp → ConvertKit → Buttondown → Loops → Beehiiv → webhook

There is no setting that picks one.

Tax has the most real branching

ENABLE_TAXES gates it entirely. Off, and getTaxProvider returns the zero-tax provider — which is what the taxes settings page already tells the operator.

On, the channel's stored taxProvider selects:

  • manual computes the flat channel rate
  • stripe_tax calls Stripe's calculation API, and falls back to the flat rate on any error or missing destination
  • Any other stored value logs a warning and falls back rather than pretending to work

Stripe Tax's commit and reverse — Stripe's own tax reporting, distinct from the customer refund — are not wired. They need the calculation id persisted on the order.

Adding a payment provider

Implement PaymentProvider, then add a branch for its name in getPaymentProvider in lib/payments/payment-providers.ts.

See lib/providers/stripe.ts for the shape. createCheckout is required; refundPayment, verifyWebhook, getCheckoutSession and findCoupon are optional.

There is no registry or priority machinery over a one-element set, and no admin control that selects a provider.

Settings → Payments reports which providers are configured and verifies the Stripe connection, but the configuration itself is STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in the environment.

Direct integrations

Some dependencies are integrated directly, because a seam would add complexity without adding flexibility.

Resend

lib/email.ts is the one send path, with two entry points: sendEmail for immediate admin-initiated sends, and queueEmail for transactional mail through Inngest.

Templates resolve from an active EmailTemplate row first, then fall back to the React components in emails/. Every send writes an EmailLog row.

Replacing Resend means editing one file.

The S3 API

Already a de-facto interface — AWS, R2 and MinIO all speak it.

The client reads S3_ENDPOINT and uses path-style addressing, so pointing it at any of them is configuration, not code.

Postgres does the identification, with no extension required.

identifyCandidates (lib/search/candidates.ts) expands synonyms from a static map, runs ILIKE over name, description, tags and category, then adds near-misses using trigram similarity computed in JavaScript — deliberately not pg_trgm.

The fuzzy and synonym phases are fail-safe: an error there never stops the ILIKE results from returning.

There is no separate search cluster to run.

Optional by default

Optional is not the same as unconfigured everywhere. env.ts splits them plainly.

Required, or the app does not boot:

DATABASE_URL
BETTER_AUTH_SECRET
BETTER_AUTH_URL
REDIS_REST_URL
REDIS_REST_TOKEN
RESEND_API_KEY
RESEND_SENDER_EMAIL
NEXT_PUBLIC_SITE_URL
NEXT_PUBLIC_SITE_EMAIL

The four S3_* credentials are required too.

Resend is required because sign-in sends a magic link.

Optional, and their surfaces hide when unset:

  • Stripe
  • Every AI provider key
  • Plausible, PostHog, GA4, Meta, Sentry
  • The Shopify app credentials
  • All newsletter providers
  • Google OAuth — the provider is registered in lib/auth.ts only when both AUTH_GOOGLE_ID and AUTH_GOOGLE_SECRET are present

A store with no AI key shows no AI buttons. A store with no Stripe key still browses and builds carts.

Redis fails open

The rate limiter runs inside the sign-in path.

So the Upstash client is configured with a single quick retry: an unreachable Redis becomes a fast fail-open rather than a long hang on "Send magic link".

The database

Litestore is built on PostgreSQL.

Constraints, append-only ledgers, and case-insensitive unique indexes are Postgres features the platform's guarantees are written against.

PostgreSQL is a deliberate foundation, not a swappable setting

Committing to one database is what makes those guarantees possible.

Where a new dependency goes

Put a new dependency behind an interface when a second implementation already exists, or when the choice is genuinely the operator's — which newsletter platform, which social network, which tax engine.

Integrate it directly when there is one implementation and no realistic second. The S3 client, the Resend send path and the Postgres search are all one file or one function away from being replaced, and an interface over them would only add a layer to read through.

Whichever way it goes, the same rule decides whether it was done correctly:

The provider moves the data; your database keeps the record. If swapping a provider would rewrite your history rather than just your integration, the seam is in the wrong place.

Next Steps

On this page