Multi-Source Payment Processing with Intelligent Webhook Routing
How I built a sophisticated payment system for The Observatory platform that handles both external contract purchases and in-app revision payments, featuring metadata-driven routing, client-side polling, and database-backed revision accounting.

Project Overview
The Observatory manages the complete lifecycle of chamber of commerce advertising contracts, from initial purchase through design approval. The platform required a payment integration to handle two distinct revenue streams: initial ad contracts purchased via an external Rate Card Landing Page, and in-app revision purchases ($150 for 2 additional revisions) for advertisers needing to exceed their included revision limit.
Background & Context
The Observatory operates a hybrid payment model: (1) Initial ad contract sales processed through an external Rate Card Landing Page system, creating pending contracts and triggering user onboarding, and (2) In-app revision purchases enabling advertisers to exceed their 3 included revisions by purchasing additional revision credits within the platform.
Key Challenges
Multi-Source Payment Attribution
The platform receives payments from two distinct sources: Observatory in-app revision purchases (with contractId metadata) and external Rate Card Landing Page purchases (with advertiserId, sizeId, publicationId metadata). A single webhook endpoint needed to intelligently route these payments to different processing logic without database lookups.
Implemented metadata-based routing in the webhook handler. Observatory purchases include contractId in metadata and trigger revision credit insertion. RLP purchases include advertiser/size/publication metadata and are simply acknowledged (processing handled by external system). This elegant solution eliminates the need for separate webhook endpoints, multiple Stripe accounts, or complex routing logic.
Webhook Delay User Experience
Users completing payment see success from Stripe immediately, but the webhook can take 1-5 seconds to arrive and process. During this delay, users would see stale data (no new revisions) and the 'Purchase Revisions' button remained disabled, creating confusion about whether payment succeeded.
Built a sophisticated client-side polling mechanism with PendingPaymentModal. When users return from Stripe redirect, the component detects the payment_intent_client_secret query parameter, verifies payment success, then polls the database every 6 seconds until revision count increases. A loading modal provides clear feedback during the wait, creating a smooth UX despite webhook latency.
Revision Accounting System
The platform needed to track available revisions across multiple purchases per contract. Formula: Available = 3 (base) + Σ(purchased) - Σ(requested). This required distinguishing between revision purchases (adds credits) and revision requests (consumes credits) while handling edge cases like multiple rapid purchases and potential webhook failures.
Implemented a database-backed accounting system with contract_revisions table storing each purchase (payment_id for idempotency, count always 2). Custom React hooks calculate available revisions: useNumRevisionsRemaining queries contract_revisions for total purchased, useNumRevisionsRequested counts ad_artwork submissions marked as revisions, then computes the difference. Additive counting prevents duplicate processing if webhooks fire multiple times.
Idempotent Webhook Processing
Stripe webhooks can be delivered multiple times or out of order. Without idempotency protection, duplicate webhook events could credit the same payment twice, giving customers free revisions. The system needed to gracefully handle retries and ensure exactly-once processing semantics.
Added unique constraint on contract_revisions.payment_id (Stripe PaymentIntent ID). First webhook processes successfully, subsequent deliveries fail the database insert due to unique constraint violation, naturally providing idempotency. Webhook handler returns 200 regardless (preventing Stripe retry storms) and logs the duplicate attempt for monitoring.
Technical Approach
The implementation uses Stripe Payment Intents API for flexible payment handling with strong 3D Secure support. Architecture separates concerns: BuyRevisionModal creates Payment Intent on modal open, CheckoutForm handles card collection and payment confirmation via Stripe Elements, PendingPaymentModal manages post-payment polling, and webhook handlers perform server-side verification and database updates. The pending payment pattern handles the asynchronous nature of webhooks gracefully.
Payment Intent Creation with Metadata
API route creates $150 Payment Intent with contractId in metadata. Query parameter approach simplifies implementation and enables caching. Metadata serves as single source of truth for webhook routing, eliminating need for database lookups during webhook processing.
Embedded Checkout with Stripe Elements
Custom CheckoutForm component integrates Stripe PaymentElement with modal UI. Uses return_url strategy pointing to same page to preserve context after 3D Secure redirects. Type-specific error handling provides clear user feedback for card errors vs. validation errors vs. unexpected failures.
Client-Side Polling Mechanism
PendingPaymentModal detects return from Stripe via payment_intent_client_secret query parameter, retrieves payment status, then polls database every 6 seconds using SWR mutate() until revision count increases. Interval chosen to balance responsiveness (typical webhook arrival 1-3s) with server load.
Metadata-Based Webhook Routing
Single webhook endpoint handles multiple payment sources. Checks metadata for contractId (Observatory) vs. advertiserId/sizeId/publicationId (RLP). Routes to appropriate processing logic or gracefully acknowledges. Returns 400 on missing/invalid metadata to trigger Stripe retry for transient errors.
Revision Accounting Hooks
React hooks encapsulate business logic: useNumRevisionsRemaining calculates available revisions from database state, useNumRevisionsRequested counts submitted artwork marked as revisions. Hooks use useMemo for performance and automatically trigger UI updates when underlying data changes via SWR revalidation.
Database-Backed Idempotency
contract_revisions table with unique constraint on payment_id ensures exactly-once processing. Atomic operations prevent race conditions. Additive counting (sum all purchases) handles multiple rapid purchases gracefully without complex locking.
Results & Impact
The payment system successfully launched in production and processes real customer transactions with zero manual intervention. The multi-source routing architecture enables seamless integration between external contract sales and in-app revision purchases. Client-side polling creates a polished user experience despite webhook latency. The revision accounting system handles edge cases gracefully, and idempotency protection prevents duplicate crediting. Most importantly, the architecture scales effortlessly to handle growth from dozens to thousands of monthly transactions.
“The revision payment system just works. Customers can purchase additional revisions instantly, and we never have to think about it. The integration handles everything automatically.”
Key Takeaways
- Stripe metadata is incredibly powerful for routing multi-source payments without database lookups
- Client-side polling is simpler than WebSockets for low-frequency async events like webhook processing
- Payment Intent API provides superior flexibility over legacy Charges API for handling authentication flows
- Always disable Next.js body parsing for webhook routes to enable signature verification with raw body
- Database unique constraints provide elegant idempotency protection without complex application logic
- Webhook delays are real-always show loading state to users after payment confirmation
- Test webhook signature verification locally using stripe CLI: stripe listen --forward-to localhost:3000
- Type-safe server actions (TypeScript + Next.js) prevent bugs in payment amount calculations and metadata handling
- SWR cache invalidation with mutate() works beautifully for polling patterns in React
- Additive accounting (sum all transactions) is more robust than maintaining running balance counters
Related Services
Interested in similar work for your project?

"I highly recommend Dean to anyone looking for a reliable and professional partner for their software development needs."
Ben Harris - CEO of Lunar Cow Publishing
Need similar work for your project?
Book a free consultation to discuss how we can help you achieve results like these.