typescript
// types/payment.ts
export interface PaymentEvent {
id: string;
provider: 'stripe' | 'paddle' | 'lemon_squeezy';
type: 'subscription.created' | 'subscription.updated' | 'payment.succeeded' | 'refund.processed';
customerId: string;
amount: number;
currency: string;
metadata: Record<string, unknown>;
timestamp: Date;
rawPayload: unknown;
}
export interface FulfillmentAdapter {
handle(event: PaymentEvent): Promise<void>;
supports(eventType: PaymentEvent['type']): boolean;
}
### Step 2: Implement Idempotent Webhook Ingestion
Webhooks are unreliable by design. Network timeouts, provider retries, and server restarts cause duplicates. Every endpoint must verify signatures, check idempotency keys, and acknowledge quickly.
```typescript
// services/webhook-ingestor.ts
import { verifyWebhookSignature } from './crypto';
import { redis } from './database';
import { normalizeEvent } from './normalizer';
import { routeEvent } from './router';
export async function handleWebhook(provider: string, rawBody: string, headers: Record<string, string>) {
// 1. Verify provider signature
const isValid = await verifyWebhookSignature(provider, rawBody, headers);
if (!isValid) throw new Error('Invalid signature');
// 2. Extract idempotency key (provider-specific)
const idempotencyKey = extractIdempotencyKey(provider, rawBody);
// 3. Check Redis for duplicate processing
const alreadyProcessed = await redis.get(`webhook:${idempotencyKey}`);
if (alreadyProcessed) return { status: 200, message: 'Duplicate ignored' };
// 4. Normalize to unified event
const event = normalizeEvent(provider, rawBody);
// 5. Mark as processed (TTL: 30 days for audit)
await redis.set(`webhook:${idempotencyKey}`, 'processed', { EX: 2592000 });
// 6. Route asynchronously
await routeEvent(event);
return { status: 200, message: 'Accepted' };
}
Step 3: Build the Event Router
The router maps normalized events to fulfillment adapters based on event type and product tier. It uses a strategy pattern to keep logic decoupled.
// services/router.ts
import { PaymentEvent, FulfillmentAdapter } from '../types/payment';
import { saasProvisioner } from '../adapters/saas';
import { digitalDelivery } from '../adapters/digital';
import { affiliatePayout } from '../adapters/affiliate';
const ADAPTERS: FulfillmentAdapter[] = [saasProvisioner, digitalDelivery, affiliatePayout];
export async function routeEvent(event: PaymentEvent) {
const relevantAdapters = ADAPTERS.filter(adapter => adapter.supports(event.type));
// Parallel execution with bounded concurrency
await Promise.allSettled(
relevantAdapters.map(adapter => adapter.handle(event))
);
// Log for reconciliation
await logRevenueEvent(event, relevantAdapters.map(a => a.constructor.name));
}
Step 4: Fulfillment Adapter Implementation
Each adapter handles domain-specific logic. The SaaS adapter provisions features; the digital adapter generates expiring download links; the affiliate adapter calculates referral commissions.
// adapters/digital.ts
import { FulfillmentAdapter, PaymentEvent } from '../types/payment';
import { generateSecureToken } from '../utils/crypto';
import { db } from '../database';
export const digitalDelivery: FulfillmentAdapter = {
supports(eventType) {
return ['payment.succeeded', 'subscription.created'].includes(eventType);
},
async handle(event) {
const downloadToken = generateSecureToken(32);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await db.digitalLinks.create({
data: {
token: downloadToken,
productId: event.metadata.productId as string,
customerId: event.customerId,
expiresAt,
used: false,
}
});
// Trigger email delivery queue
await queue.send('email:deliver-digital', {
email: event.metadata.customerEmail as string,
link: `https://app.yourdomain.com/download/${downloadToken}`,
product: event.metadata.productName as string,
});
}
};
Architecture Decisions and Rationale
- Event-Driven over Synchronous: Payment confirmation should never block the user interface. Webhooks are processed asynchronously, ensuring the frontend remains responsive and the system survives provider outages.
- Idempotency via Redis: Storing processed webhook keys prevents duplicate provisioning, which causes support tickets, refund requests, and revenue reconciliation errors.
- Provider Abstraction: Normalizing payloads decouples business logic from provider SDKs. Switching from Stripe to Paddle for tax compliance requires only a new normalizer, not a core rewrite.
- Bounded Concurrency in Routing:
Promise.allSettled ensures one failing adapter (e.g., affiliate payout service down) doesn't block critical fulfillment (e.g., SaaS access).
- Audit-First Design: Every event is logged with adapter outcomes. This enables automated reconciliation, fraud detection, and financial reporting without manual spreadsheet work.
Pitfall Guide
1. Hardcoding Provider SDKs into Business Logic
Mistake: Importing @stripe/stripe-node directly into subscription management or checkout flows.
Impact: Provider changes require refactoring core domains. Testing becomes provider-dependent.
Best Practice: Always route through a normalized event interface. Keep provider SDKs confined to the ingestion layer.
2. Ignoring Webhook Idempotency and Retry Logic
Mistake: Assuming webhooks fire exactly once. Processing duplicates causes double charges, duplicate licenses, or broken state.
Impact: 12-15% revenue leakage, support overload, accounting mismatches.
Best Practice: Implement signature verification, idempotency key storage, and exponential backoff for downstream calls. Acknowledge webhooks within 3 seconds.
3. Coupling Fulfillment to Frontend State
Mistake: Granting access only after a successful frontend redirect or client-side JavaScript execution.
Impact: Users with ad blockers, slow networks, or browser crashes never receive access. Revenue is collected, value is not delivered.
Best Practice: Fulfillment must be server-driven and event-triggered. Frontend should poll or subscribe to state changes, not control access.
4. Neglecting Tax/VAT Automation Architecture
Mistake: Treating tax calculation as a frontend concern or manual spreadsheet task.
Impact: Compliance violations, retroactive penalties, cross-border sales restrictions.
Best Practice: Use a Merchant of Record (Paddle, Lemon Squeezy) for global tax handling, or integrate a tax API (TaxJar, Stripe Tax) at the normalization layer. Never store tax logic in client code.
5. Over-Indexing on Real-Time Sync
Mistake: Forcing synchronous database updates across payment, fulfillment, and analytics systems.
Impact: Latency spikes, cascading failures, degraded UX during payment processing.
Best Practice: Embrace eventual consistency. Use message queues for non-critical updates (analytics, affiliate payouts, email delivery). Keep access control decoupled from billing state.
6. Missing Revenue Reconciliation Trails
Mistake: Relying on provider dashboards for financial tracking without an internal ledger.
Impact: Inability to detect failed webhooks, calculate true MRR, or prepare for audits.
Best Practice: Maintain an immutable revenue event log. Reconcile daily against provider payouts using automated scripts. Flag discrepancies >$0.01 for review.
7. Treating Affiliate/Referral as an Afterthought
Mistake: Adding referral tracking post-launch with client-side cookies and manual payout calculations.
Impact: Fraud, attribution disputes, delayed payments, partner churn.
Best Practice: Integrate referral attribution at the event router level. Use cryptographically signed referral links, server-side cookie parsing, and automated commission calculation tied to successful payment events.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Bootstrapped solo dev (<$5k MRR) | Single MoR (Paddle/Lemon Squeezy) + lightweight webhook router | Offloads tax compliance, reduces legal overhead, fast launch | Low setup cost, 5-8% transaction fee |
| Scaling to 6-figures ($10k-$50k MRR) | Multi-provider routing (Stripe + MoR) + event-driven fulfillment | Enables regional optimization, A/B pricing, affiliate programs | Medium engineering cost, 2-5% savings on fees |
| Enterprise-grade compliance (>50k MRR) | Custom payment orchestrator + dedicated reconciliation ledger + tax API | Full auditability, custom routing rules, multi-entity support | High engineering cost, reduces compliance risk by 90% |
Configuration Template
// config/revenue-stack.ts
import { RevenueRouter } from './services/router';
import { saasProvisioner } from './adapters/saas';
import { digitalDelivery } from './adapters/digital';
import { affiliatePayout } from './adapters/affiliate';
import { taxNormalizer } from './normalizers/tax';
import { redisClient } from './database';
export const revenueConfig = {
providers: {
stripe: {
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
normalizer: 'stripe',
enabled: true,
},
paddle: {
webhookSecret: process.env.PADDLE_WEBHOOK_SECRET!,
normalizer: 'paddle',
enabled: true,
},
},
adapters: [saasProvisioner, digitalDelivery, affiliatePayout],
middleware: [taxNormalizer],
storage: {
idempotency: redisClient,
ttl: 2592000, // 30 days
},
routing: {
concurrency: 3,
retryAttempts: 2,
timeoutMs: 5000,
},
};
export const router = new RevenueRouter(revenueConfig);
Quick Start Guide
- Initialize the project structure: Create
types/, services/, adapters/, normalizers/, and config/ directories. Add the unified PaymentEvent interface and FulfillmentAdapter contract.
- Deploy the webhook ingestor: Set up a serverless function or Express route that verifies provider signatures, checks Redis for idempotency, normalizes the payload, and queues it for routing.
- Wire the adapters: Implement at least two fulfillment adapters (e.g., SaaS provisioning + digital delivery). Register them in the router configuration.
- Test with provider sandboxes: Use Stripe Test Mode or Paddle Sandbox to trigger
payment.succeeded and subscription.created events. Verify idempotency by replaying the same webhook twice. Confirm adapters execute without blocking the HTTP response.
- Attach to your product: Replace hardcoded checkout logic with a redirect to your unified payment flow. Ensure access control reads from the fulfillment state, not the frontend session. Deploy and monitor the first 50 events for latency and success rates.