Stripe Billing
The marketplace uses Stripe for every paid pricing tier. Free tiers are pure DB rows — no Stripe interaction.
Mapping
| Marketplace concept | Stripe concept |
|---|---|
| Paid pricing tier | One Stripe Product, one Stripe Price |
| First paid checkout for a user | Stripe Customer + Subscription |
| User upgrades / downgrades | Stripe customer.subscription.updated |
| User cancels | cancelAtPeriodEnd: true → Stripe customer.subscription.deleted at period end |
| Failed payment | Stripe invoice.payment_failed → marketplace marks past_due |
Product + Price creation
When you POST /agents/:agentId/pricing with isFree: false:
// inside PricingService.create
const product = await stripe.products.create({ name: tierName });
const price = await stripe.prices.create({
product: product.id,
unit_amount: parseDecimalToCents(price),
currency,
recurring: billingInterval !== "one_time"
? { interval: billingInterval } // 'month' | 'year'
: undefined,
});
await db.update(agent_pricing_tiers).set({
stripeProductId: product.id,
stripePriceId: price.id,
});
STRIPE_SECRET_KEY must be configured. Without it, paid tier creation will fail with SERVICE_UNAVAILABLE.
Checkout
POST /agents/:agentId/pricing/:tierId/checkout body { returnUrl }.
For paid tiers, the handler:
- Validates
returnUrlagainst the agent'sredirectUris[],productDomain, and (in dev) localhost. - Calls
stripe.checkout.sessions.create({ mode: 'subscription', line_items: [{ price: stripePriceId, quantity: 1 }], success_url: returnUrl, cancel_url: returnUrl, customer_email: ..., metadata: { userId, agentId, tierId } }). - Returns
{ status: 'redirect', url: session.url }.
The browser follows the URL, the user pays, Stripe redirects to returnUrl, and Stripe sends checkout.session.completed to the marketplace webhook.
Webhook handler
POST /webhooks/stripe
Events handled:
| Event | Effect |
|---|---|
checkout.session.completed | Upsert user_agent_subscriptions with status from session; emit subscription_created interaction |
customer.subscription.created | Same upsert path |
customer.subscription.updated | Sync status, currentPeriodEnd, cancelAtPeriodEnd |
customer.subscription.deleted | Set status: 'canceled', canceledAt: now() |
invoice.payment_succeeded | Audit log only |
invoice.payment_failed | Set status: 'past_due' |
Idempotency
stripe_events.stripeEventId is unique. The first INSERT wins; duplicates short-circuit. If a handler throws, errorMessage is set and processedAt stays NULL — you can retry the event by clearing errorMessage and rerunning the dispatcher (operations script).
Signature verification
stripe.webhooks.constructEvent(rawBody, signatureHeader, STRIPE_WEBHOOK_SECRET) validates the request came from Stripe. Set STRIPE_WEBHOOK_SECRET from the Stripe Dashboard. Raw body parsing is critical here — the controller uses @Res() to bypass NestJS's JSON middleware and read the raw Buffer.
Stripe Customer Portal
Not currently wired. Self-service cancel/upgrade goes through the marketplace dashboard, which calls POST /me/subscriptions/:agentId/cancel. Add the portal if needed:
const session = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${FRONTEND_URL}/dashboard`,
});
See also
- Pricing tier
- Subscription
- Flow: Subscription checkout
- Backend: Stripe integration
- The existing internal doc
marketplace-fleapoai-service/docs/PAYMENT-INTEGRATION.md.