Skip to main content

Stripe Billing

The marketplace uses Stripe for every paid pricing tier. Free tiers are pure DB rows — no Stripe interaction.

Mapping

Marketplace conceptStripe concept
Paid pricing tierOne Stripe Product, one Stripe Price
First paid checkout for a userStripe Customer + Subscription
User upgrades / downgradesStripe customer.subscription.updated
User cancelscancelAtPeriodEnd: true → Stripe customer.subscription.deleted at period end
Failed paymentStripe 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:

  1. Validates returnUrl against the agent's redirectUris[], productDomain, and (in dev) localhost.
  2. 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 } }).
  3. 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:

EventEffect
checkout.session.completedUpsert user_agent_subscriptions with status from session; emit subscription_created interaction
customer.subscription.createdSame upsert path
customer.subscription.updatedSync status, currentPeriodEnd, cancelAtPeriodEnd
customer.subscription.deletedSet status: 'canceled', canceledAt: now()
invoice.payment_succeededAudit log only
invoice.payment_failedSet 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