Skip to main content

Stripe integration

The marketplace touches Stripe in three places:

WhereWhat
src/pricing/pricing.service.tsCreate Product + Price when a paid tier is created; archive on delete
src/subscriptions/subscriptions.service.tsCreate Checkout Sessions for paid tier checkout
src/webhooks/stripe-webhook.{controller,service}.tsReceive 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:

  1. 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.
  2. Copy the Signing secretSTRIPE_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.