Stripe integration
The marketplace touches Stripe in three places:
| Where | What |
|---|---|
src/pricing/pricing.service.ts | Create Product + Price when a paid tier is created; archive on delete |
src/subscriptions/subscriptions.service.ts | Create Checkout Sessions for paid tier checkout |
src/webhooks/stripe-webhook.{controller,service}.ts | Receive webhooks, verify signatures, dispatch to handlers |
SDK module
src/stripe/stripe.module.ts provides a STRIPE token:
{
provide: STRIPE,
inject: [AppConfigService],
useFactory: (config: AppConfigService) =>
new Stripe(config.stripeSecretKey, { apiVersion: "2025-01-01" }),
}
Injected as @Inject(STRIPE) private readonly stripe: Stripe.
Product + Price creation (PricingService)
When POST /agents/:agentId/pricing creates a non-free tier:
const product = await this.stripe.products.create({ name: tierName });
const price = await this.stripe.prices.create({
product: product.id,
unit_amount: parseDecimalToCents(price),
currency,
recurring: billingInterval !== "one_time"
? { interval: billingInterval }
: undefined,
});
await db.update(agent_pricing_tiers).set({
stripeProductId: product.id,
stripePriceId: price.id,
});
If STRIPE_SECRET_KEY is absent, the call throws SERVICE_UNAVAILABLE.
Checkout Session creation (SubscriptionsService)
const session = await this.stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: tier.stripePriceId, quantity: 1 }],
success_url: `${returnUrl}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: returnUrl,
customer_email: user.email,
metadata: { userId, agentId, tierId },
});
return { status: "redirect", url: session.url! };
returnUrl must pass validateReturnUrl() first (allow-list against agent.redirectUris + productDomain).
Webhook controller (raw body)
@Controller("webhooks/stripe")
export class StripeWebhookController {
constructor(private readonly svc: StripeWebhookService) {}
@Post()
async handle(
@Req() req: RawBodyRequest<Request>,
@Headers("stripe-signature") signature: string,
@Res() res: Response,
) {
if (!req.rawBody) {
return res.status(400).send("No raw body");
}
try {
const event = await this.svc.verify(req.rawBody, signature);
await this.svc.dispatch(event);
return res.status(200).send("ok");
} catch (err) {
return res.status(400).send(err.message);
}
}
}
Note @Res() — bypasses ApiResponseInterceptor. Stripe expects a bare 200 ok.
Webhook service flow
Idempotency
stripe_events.stripeEventId UNIQUE + ON CONFLICT DO NOTHING is the entire idempotency strategy. If a handler throws, errorMessage is set, processedAt stays NULL — a manual reset and retry path is needed for failed events. Build:
pnpm tsx scripts/retry-stripe-events.ts --since=2026-05-14
(If absent, write it when you hit the first stuck event.)
Configuring Stripe
# .env
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
In the Stripe Dashboard:
- Developers → Webhooks → Add endpoint:
https://api.fleapo.ai/webhooks/stripe, events:checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_succeeded,invoice.payment_failed. - Copy the Signing secret →
STRIPE_WEBHOOK_SECRET.
Test mode vs live
Two sets of keys, two webhook endpoints (test + live point at different envs of the marketplace). The Stripe library handles both transparently — the key prefix determines the mode.
Customer Portal (future)
Not wired today. Self-service upgrade goes through marketplace's own UI calling POST /agents/:agentId/pricing/:tierId/checkout again. Add the billing portal if you want one-click plan switching:
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${frontendUrl}/dashboard`,
});
See also: Concept: Stripe billing, Flow: Subscription lifecycle.