Payment Provider Router
Concept of the skill
What it is: The typed dispatch layer that sends verified payment events to the correct business handler.
Mental model: Verification proves the event is authentic; the router decides what business operation the event represents.
Why it exists: Payment events are high-impact and retried by providers, so ambiguous routing can duplicate work or miss fulfillment.
What it is NOT: It is not webhook signature verification, subscription database writes, or provider SDK setup.
Adjacent concepts: Event type maps, handler isolation, provider abstraction, idempotency.
One-line analogy: It is the switchboard that sends a verified payment event to the right desk.
Common misconception: Unknown events should return an HTTP error; for Stripe, acknowledging and logging unknown-but-valid events prevents retry storms.
Coverage
- The routing table — a typed dispatch map from
Stripe.Event["type"] to handler functions, with a structured "unknown event" fallback that returns 200 (to prevent Stripe retry storms) and logs the unhandled type
- Handler isolation — each handler receives only the specific event subtype it needs (e.g.
Stripe.CheckoutSessionCompletedEvent), not the generic Stripe.Event, to avoid casts inside handlers
- Provider abstraction — how to wrap the Stripe-specific router behind a
PaymentEvent canonical type so a future provider can be added without changing handler code
- Error surface — handlers must catch their own errors and return a structured result; an uncaught exception must not produce a 500 that triggers Stripe's retry mechanism with an exponential backoff cascade
- Event type coverage audit — which event types are handled, which are known-ignored (acknowledged with a comment), and which are genuinely unknown
Philosophy of the skill
A payment event router has the same discipline requirement as a content source router: prefer an explicit handler over an implicit fallback, surface unhandled events loudly (in logs, not in HTTP status codes — a 400 triggers a retry, a 200 with a log entry does not), and never let one handler own two semantically distinct events. The event type is the authoritative signal for which business operation to perform; ambiguity at this layer produces double-charges, missed provisioning, and unfired dunning emails.
Routing Rules
// lib/payments/router.ts
import Stripe from "stripe";
import { handleCheckoutComplete } from "./handlers/checkout-complete";
import { handlePaymentFailed } from "./handlers/payment-failed";
import { handleSubscriptionDeleted } from "./handlers/subscription-deleted";
type HandlerResult = { ok: boolean; message?: string };
const EVENT_HANDLERS: Partial<
Record<Stripe.Event["type"], (event: Stripe.Event) => Promise<HandlerResult>>
> = {
"checkout.session.completed": (e) =>
handleCheckoutComplete(e as Stripe.CheckoutSessionCompletedEvent),
"invoice.payment_failed": (e) =>
handlePaymentFailed(e as Stripe.InvoicePaymentFailedEvent),
"customer.subscription.deleted": (e) =>
handleSubscriptionDeleted(e as Stripe.CustomerSubscriptionDeletedEvent),
// Acknowledged non-actionable events — log and return OK
"invoice.paid": async () => ({ ok: true, message: "acknowledged" }),
};
export async function routePaymentEvent(event: Stripe.Event): Promise<HandlerResult> {
const handler = EVENT_HANDLERS[event.type];
if (!handler) {
console.warn("[payment-router] unhandled event type", { type: event.type, id: event.id });
// Return 200 — a 4xx or 5xx would trigger Stripe retry with backoff
return { ok: true, message: "unhandled_event_type" };
}
return handler(event);
}
Routing Decision Rules
| Event type |
Handler |
Rationale |
checkout.session.completed |
handleCheckoutComplete |
Provision subscription, create org record |
invoice.payment_failed |
handlePaymentFailed |
Trigger dunning, update subscription status |
customer.subscription.deleted |
handleSubscriptionDeleted |
Revoke access, archive subscription |
invoice.paid |
acknowledged |
No action — success is implicit from checkout.session.completed |
| anything else |
log + 200 |
Unknown event — log for triage, do not retry |
Adding a New Event Type
- Add the Stripe event type string to
EVENT_HANDLERS with a typed cast.
- Write the handler in
lib/payments/handlers/<name>.ts — it receives the specific subtype.
- Add a row to the routing table above documenting what the handler does.
- If the event should be intentionally ignored, add it to the "acknowledged" row rather than leaving it in the unknown bucket.
Verification
Do NOT Use When
| Use instead |
When |
stripe-webhook-signature-verification |
The task is verifying the event's authenticity before routing |
postgres-rls-pattern |
The task is writing the database statements inside a specific handler |
| (a generic event bus skill) |
The application uses an event bus that is not payment-provider-specific |
1---2# name: stable skill identifier. Match the skill directory name or the final namespace segment.3# Lowercase letters/numbers with hyphen, slash, or colon separators.4name: payment-provider-router5# description: routing-facing summary of what the skill covers and when it activates.6# Include concrete triggers and an explicit negative boundary; keep routing semantics out of prose-only ambiguity.7description: "Use when dispatching a verified payment event (Stripe webhook or future provider) to the correct downstream handler based on event type. Routes `checkout.session.completed` to subscription provisioning, `invoice.payment_failed` to dunning logic, and `customer.subscription.deleted` to cancellation. Do NOT use for signature verification of the incoming event (use stripe-webhook-signature-verification first) or for the actual subscription database writes (use the per-handler skill or postgres-rls-pattern)."89# === v8 Classification (subject + public; polyhierarchy via subjects[]) — see ADR-0017 ===10# subject: primary browse shelf — what the skill teaches. One of twelve closed values:11# backend-engineering / frontend-engineering / software-architecture / data-engineering / agent-ops / ai-engineering /12# quality-assurance / design / reasoning-strategy / software-engineering-method / knowledge-organization / product-domain.13subject: backend-engineering14# public: publishability/private-data gate. Boolean.15# true = publishable/shareable; false = private and excluded from public export.16# Project anchoring is carried separately by non-empty `project[]` plus `grounding`.17public: true18# scope: free-text PRD-style statement of what the skill teaches and what it excludes.19# (v8 required; not an enum). Mirrors Coverage + Do NOT Use When at frontmatter level.20scope: "Dispatching a verified payment event to the correct downstream handler by event type in the saas-stripe-postgres example. Excludes webhook signature verification (stripe-webhook-signature-verification) and the subscription database writes (postgres-rls-pattern)."21# taxonomy_domain: optional hierarchical sub-path within `subject`. Slash-delimited22# lowercase kebab-case segments. rename of the original v8 `domain`. Remove when the flat23# `subject` is sufficient.24taxonomy_domain: engineering/payments2526# stability: lifecycle marker. One of:27# experimental (active development) / stable (production-ready) /28# frozen (no further changes expected) / deprecated.29# When `deprecated`, schema's allOf REQUIRES `superseded_by: <real-skill-name>`.30stability: experimental31# license: SPDX license identifier (e.g., MIT, Apache-2.0).32license: MIT33# compatibility: runtime compatibility object. Prefer structured fields34# (`agent_runtimes`, `node_version`) over free-text `notes`.35compatibility:36 agent_runtimes:37 - node38 node_version: ">=20"39 notes: "Stripe SDK >=14; event types sourced from Stripe's published event catalog."40allowed-tools: Read Grep41# keywords: semantic phrases for fuzzy router activation. v8 cap: max 10.42# Keep terms a user would actually type when starting a task in this skill's domain.43keywords:44 - payment event routing45 - stripe event type dispatch46 - webhook event router47 - checkout.session.completed48 - invoice.payment_failed49 - customer.subscription.deleted50 - payment provider dispatch51 - event type switch52 - payment event handler53 - multi-provider payment routing54# triggers: explicit-match activation phrases the router fires on literally.55# Use when label-based routing is intended; usually keywords + examples are enough.56triggers:57 - payment-provider-router58# paths: glob array of code surfaces this skill governs. Supports gitignore-style59# negation. Each glob should map to ONE canonical skill. Omit if purely conceptual.60paths:61 - "lib/payments/router.ts"62 - "app/api/webhooks/stripe/route.ts"63# examples: 2-5 realistic user prompts the skill SHOULD activate for.64# Written in the user's voice. Improves retrieval recall beyond keywords alone.65examples:66 - "route checkout.session.completed to subscription provisioning"67 - "which handler should process invoice.payment_failed for dunning?"68 - "add a new event type handler for customer.subscription.updated"69 - "design the event router so it is extensible to a second payment provider"70# anti_examples: near-miss prompts that should route ELSEWHERE.71# Pair with relations.suppresses (or legacy boundary alias) to name the confusable territory's owner.72anti_examples:73 - "verify that the webhook request is genuinely from Stripe"74 - "write the database insert that creates the subscription record"75 - "handle a failed Stripe API call when creating a payment intent"76# relations: typed graph edges to sibling skills. Current fields:77# related (adjacency for browse / co-routing expansion) /78# suppresses (exclude listed skills from co-routing when THIS skill wins; write reason79# as "I own this exclusively over X", not "use X instead") /80# boundary (DEPRECATED alias of suppresses, retained for unmigrated skills) /81# verify_with (cross-check; co-loaded as one-hop expansion) /82# depends_on (composition; transitive — A→B→C loads all three) /83# broader / narrower (SKOS-style generalization) /84# disjoint_with (mutual exclusion for incompatible ownership).85relations:86 related:87 - stripe-webhook-signature-verification88 - postgres-rls-pattern89 depends_on:90 - stripe-webhook-signature-verification91 verify_with:92 - stripe-webhook-signature-verification93---9495# Payment Provider Router9697## Concept of the skill9899**What it is:** The typed dispatch layer that sends verified payment events to the correct business handler.100**Mental model:** Verification proves the event is authentic; the router decides what business operation the event represents.101**Why it exists:** Payment events are high-impact and retried by providers, so ambiguous routing can duplicate work or miss fulfillment.102**What it is NOT:** It is not webhook signature verification, subscription database writes, or provider SDK setup.103**Adjacent concepts:** Event type maps, handler isolation, provider abstraction, idempotency.104**One-line analogy:** It is the switchboard that sends a verified payment event to the right desk.105**Common misconception:** Unknown events should return an HTTP error; for Stripe, acknowledging and logging unknown-but-valid events prevents retry storms.106107## Coverage108109- The routing table — a typed dispatch map from `Stripe.Event["type"]` to handler functions, with a structured "unknown event" fallback that returns 200 (to prevent Stripe retry storms) and logs the unhandled type110- Handler isolation — each handler receives only the specific event subtype it needs (e.g. `Stripe.CheckoutSessionCompletedEvent`), not the generic `Stripe.Event`, to avoid casts inside handlers111- Provider abstraction — how to wrap the Stripe-specific router behind a `PaymentEvent` canonical type so a future provider can be added without changing handler code112- Error surface — handlers must catch their own errors and return a structured result; an uncaught exception must not produce a 500 that triggers Stripe's retry mechanism with an exponential backoff cascade113- Event type coverage audit — which event types are handled, which are known-ignored (acknowledged with a comment), and which are genuinely unknown114115## Philosophy of the skill116117A payment event router has the same discipline requirement as a content source router: prefer an explicit handler over an implicit fallback, surface unhandled events loudly (in logs, not in HTTP status codes — a 400 triggers a retry, a 200 with a log entry does not), and never let one handler own two semantically distinct events. The event type is the authoritative signal for which business operation to perform; ambiguity at this layer produces double-charges, missed provisioning, and unfired dunning emails.118119## Routing Rules120121```typescript122// lib/payments/router.ts123import Stripe from "stripe";124import { handleCheckoutComplete } from "./handlers/checkout-complete";125import { handlePaymentFailed } from "./handlers/payment-failed";126import { handleSubscriptionDeleted } from "./handlers/subscription-deleted";127128type HandlerResult = { ok: boolean; message?: string };129130const EVENT_HANDLERS: Partial<131 Record<Stripe.Event["type"], (event: Stripe.Event) => Promise<HandlerResult>>132> = {133 "checkout.session.completed": (e) =>134 handleCheckoutComplete(e as Stripe.CheckoutSessionCompletedEvent),135 "invoice.payment_failed": (e) =>136 handlePaymentFailed(e as Stripe.InvoicePaymentFailedEvent),137 "customer.subscription.deleted": (e) =>138 handleSubscriptionDeleted(e as Stripe.CustomerSubscriptionDeletedEvent),139 // Acknowledged non-actionable events — log and return OK140 "invoice.paid": async () => ({ ok: true, message: "acknowledged" }),141};142143export async function routePaymentEvent(event: Stripe.Event): Promise<HandlerResult> {144 const handler = EVENT_HANDLERS[event.type];145146 if (!handler) {147 console.warn("[payment-router] unhandled event type", { type: event.type, id: event.id });148 // Return 200 — a 4xx or 5xx would trigger Stripe retry with backoff149 return { ok: true, message: "unhandled_event_type" };150 }151152 return handler(event);153}154```155156## Routing Decision Rules157158| Event type | Handler | Rationale |159|---|---|---|160| `checkout.session.completed` | `handleCheckoutComplete` | Provision subscription, create org record |161| `invoice.payment_failed` | `handlePaymentFailed` | Trigger dunning, update subscription status |162| `customer.subscription.deleted` | `handleSubscriptionDeleted` | Revoke access, archive subscription |163| `invoice.paid` | acknowledged | No action — success is implicit from `checkout.session.completed` |164| anything else | log + 200 | Unknown event — log for triage, do not retry |165166## Adding a New Event Type1671681. Add the Stripe event type string to `EVENT_HANDLERS` with a typed cast.1692. Write the handler in `lib/payments/handlers/<name>.ts` — it receives the specific subtype.1703. Add a row to the routing table above documenting what the handler does.1714. If the event should be intentionally ignored, add it to the "acknowledged" row rather than leaving it in the unknown bucket.172173## Verification174175- [ ] Every routable event type is in `EVENT_HANDLERS` with an explicit handler or acknowledgement176- [ ] Unknown events return 200 (not 400 or 500) to prevent Stripe retry cascades177- [ ] Each handler receives a typed subtype, not the generic `Stripe.Event`178- [ ] Handler errors are caught inside the handler and returned as `{ ok: false }` — they do not propagate to the router179- [ ] `routePaymentEvent` is only called after signature verification (grep for `routePaymentEvent` — every call site should be downstream of `constructEvent`)180181## Do NOT Use When182183| Use instead | When |184|---|---|185| `stripe-webhook-signature-verification` | The task is verifying the event's authenticity before routing |186| `postgres-rls-pattern` | The task is writing the database statements inside a specific handler |187| (a generic event bus skill) | The application uses an event bus that is not payment-provider-specific |