Stripe Integration
Payment processing with Stripe Payment Links and webhooks
Stripe Integration
SavvySolve uses Stripe for payment processing after sessions are completed. Rather than requiring upfront payment or complex checkout flows, we use Stripe Payment Links to send customers a simple payment URL via SMS after their session ends.
Overview
The payment flow follows this sequence:
- Session completes - Solver marks the session as done
- Price calculated - Based on session tier and duration
- Payment Link created - Stripe generates a unique payment URL
- Link sent to customer - Via SMS and displayed in the session interface
- Customer pays - Opens link in browser, completes payment
- Webhook confirms - Stripe notifies us of successful payment
- Session marked paid - Database updated, solver earnings credited
We use Payment Links rather than Checkout Sessions because they're simpler for customers - just a URL that can be sent via SMS and opened on any device.
Setup
Create a Stripe Account
- Go to dashboard.stripe.com and create an account
- Complete business verification
Get API Keys
- Navigate to Developers → API keys
- Copy the Secret key (starts with
sk_) - For testing, use the test mode keys (starts with
sk_test_)
STRIPE_SECRET_KEY="sk_test_..."Configure Webhooks
- Go to Developers → Webhooks
- Click Add endpoint
- Set the endpoint URL:
https://your-domain.com/api/webhooks/stripe - Select events to listen for:
checkout.session.completedcheckout.session.expiredpayment_intent.payment_failed
- Copy the Signing secret (starts with
whsec_)
STRIPE_WEBHOOK_SECRET="whsec_..."For local development, use the Stripe CLI to forward webhook events:
stripe listen --forward-to localhost:3000/api/webhooks/stripeThis will give you a local webhook secret to use.
Pricing Tiers
SavvySolve offers three pricing tiers based on session complexity:
| Tier | Name | Base Price | Included Minutes | Overage Rate |
|---|---|---|---|---|
quick | Quick Assist | $69.00 | 20 min | $3.00/min |
standard | Standard Solve | $129.00 | 45 min | $2.50/min |
extended | Deep Dive | $219.00 | 90 min | $2.00/min |
export const PRICING_TIERS = {
quick: {
name: "Quick Assist",
basePrice: 6900, // $69.00 in cents
includedMinutes: 20,
overageRate: 300, // $3.00/min in cents
},
standard: {
name: "Standard Solve",
basePrice: 12900,
includedMinutes: 45,
overageRate: 250,
},
extended: {
name: "Deep Dive",
basePrice: 21900,
includedMinutes: 90,
overageRate: 200,
},
} as const;Architecture
Stripe Client
The Stripe client is a singleton that initializes lazily:
import Stripe from "stripe";
let stripeClient: Stripe | null = null;
export function getStripeClient(): Stripe {
if (!stripeClient) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error("STRIPE_SECRET_KEY environment variable is not set");
}
stripeClient = new Stripe(secretKey, {
apiVersion: "2025-12-15.clover",
});
}
return stripeClient;
}Price Calculation
The calculateSessionPrice function computes the total based on tier and actual duration:
export function calculateSessionPrice(
tier: PricingTier,
durationSeconds: number,
solverSharePercent: number = 65
): PricingCalculation {
const tierConfig = PRICING_TIERS[tier];
const durationMinutes = Math.ceil(durationSeconds / 60);
// Calculate overage
const overageMinutes = Math.max(0, durationMinutes - tierConfig.includedMinutes);
const overageCharge = overageMinutes * tierConfig.overageRate;
// Total price
const totalPrice = tierConfig.basePrice + overageCharge;
// Split between solver and platform
const solverShare = solverSharePercent / 100;
const solverEarnings = Math.round(totalPrice * solverShare);
const platformFee = totalPrice - solverEarnings;
return {
tier,
tierName: tierConfig.name,
basePrice: tierConfig.basePrice,
durationMinutes,
includedMinutes: tierConfig.includedMinutes,
overageMinutes,
overageRate: tierConfig.overageRate,
overageCharge,
totalPrice,
solverEarnings,
platformFee,
};
}Payment Link Creation
When a session completes, we create a Stripe Payment Link:
export async function createPaymentLink(
sessionId: string,
solverId: string,
amount: number,
description: string,
customerEmail?: string
): Promise<PaymentLinkResult> {
const stripe = getStripeClient();
// Create a price for this specific session
const price = await stripe.prices.create({
currency: "usd",
unit_amount: amount,
product_data: {
name: description,
metadata: { sessionId, solverId },
},
});
// Create the payment link
const paymentLink = await stripe.paymentLinks.create({
line_items: [{ price: price.id, quantity: 1 }],
metadata: { sessionId, solverId },
after_completion: {
type: "redirect",
redirect: {
url: `${process.env.NEXT_PUBLIC_APP_URL}/session/${sessionId}/thank-you`,
},
},
});
return {
success: true,
paymentLinkId: paymentLink.id,
paymentLinkUrl: paymentLink.url,
};
}Session Completion Flow
When a solver completes a session, the sessions.complete tRPC procedure handles the payment link creation:
complete: protectedProcedure
.input(completeSessionSchema)
.mutation(async ({ ctx, input }) => {
// Calculate pricing
const pricing = calculateSessionPrice(tier, durationSeconds);
// Create payment link
const paymentResult = await createPaymentLink(
sessionId,
solverId,
pricing.totalPrice,
`${pricing.tierName} - ${pricing.durationMinutes} minutes`,
customerEmail
);
// Send SMS to customer
await sendPaymentLink(
customerPhone,
sessionId,
pricing.totalPrice,
paymentResult.paymentLinkUrl
);
// Update session with payment info
await db.update(sessions).set({
status: "completed",
paymentInfo: {
stripePaymentLinkId: paymentResult.paymentLinkId,
status: "pending",
},
});
});Webhook Handler
The webhook handler processes Stripe events to update payment status:
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
// Verify webhook signature
const event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object);
break;
case "checkout.session.expired":
await handleCheckoutExpired(event.data.object);
break;
case "payment_intent.payment_failed":
await handlePaymentFailed(event.data.object);
break;
}
return NextResponse.json({ received: true });
}Checkout Completed
When payment succeeds, we update the session and ticket:
async function handleCheckoutCompleted(checkoutSession: Stripe.Checkout.Session) {
const sessionId = checkoutSession.metadata?.sessionId;
await db.update(sessions).set({
paymentInfo: {
stripePaymentIntentId: checkoutSession.payment_intent as string,
status: "paid",
paidAt: new Date().toISOString(),
},
}).where(eq(sessions.id, sessionId));
// Mark ticket as completed
await db.update(tickets).set({
status: "completed",
}).where(eq(tickets.id, session.ticketId));
}Customer UI
The PaymentStatus component displays payment state and provides the pay button:
export function PaymentStatus({
totalAmount,
paymentInfo,
paymentLinkUrl,
}: PaymentStatusProps) {
const status = paymentInfo?.status || "pending";
return (
<div className="rounded-2xl border bg-white p-6">
{/* Status indicator */}
<StatusIcon status={status} />
{/* Amount display */}
<div className="mt-6 rounded-xl bg-slate-50 p-4">
<span className="text-2xl font-bold">
{formatAmount(totalAmount)}
</span>
</div>
{/* Pay Now button */}
{status === "pending" && paymentLinkUrl && (
<Button onClick={() => window.open(paymentLinkUrl, "_blank")}>
Pay Now - {formatAmount(totalAmount)}
</Button>
)}
{/* Success message */}
{status === "paid" && (
<div className="text-emerald-700">
A receipt has been sent to your email.
</div>
)}
</div>
);
}The component handles four payment states:
| Status | Display | Action |
|---|---|---|
pending | Clock icon, amber | Show "Pay Now" button |
paid | Check icon, green | Show receipt confirmation |
failed | Alert icon, red | Show "Try Again" button |
refunded | Refresh icon, gray | Show refund confirmation |
Revenue Split
Solver earnings are calculated as a percentage of the total session price:
const DEFAULT_SOLVER_SHARE = 0.65; // 65%
// In pricing calculation
const solverEarnings = Math.round(totalPrice * solverShare);
const platformFee = totalPrice - solverEarnings;For a Quick Assist session ($69):
- Solver earnings: $44.85 (65%)
- Platform fee: $24.15 (35%)
Testing
Test Mode
Use Stripe test mode for development:
STRIPE_SECRET_KEY="sk_test_..."Test Cards
| Card Number | Result |
|---|---|
4242 4242 4242 4242 | Success |
4000 0000 0000 0002 | Card declined |
4000 0000 0000 9995 | Insufficient funds |
Webhook Testing
Use Stripe CLI for local webhook testing:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login to your Stripe account
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger test events
stripe trigger checkout.session.completedEnvironment Variables
| Variable | Description | Required |
|---|---|---|
STRIPE_SECRET_KEY | Stripe API secret key | Yes |
STRIPE_WEBHOOK_SECRET | Webhook signing secret | Yes |
NEXT_PUBLIC_APP_URL | App base URL (for redirects) | Yes |
Related Documentation
- Session Interface - Where sessions are completed
- Telnyx Integration - SMS delivery of payment links
- Customer Session - Customer payment experience