SavvySolve Docs

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:

  1. Session completes - Solver marks the session as done
  2. Price calculated - Based on session tier and duration
  3. Payment Link created - Stripe generates a unique payment URL
  4. Link sent to customer - Via SMS and displayed in the session interface
  5. Customer pays - Opens link in browser, completes payment
  6. Webhook confirms - Stripe notifies us of successful payment
  7. 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

  1. Go to dashboard.stripe.com and create an account
  2. Complete business verification

Get API Keys

  1. Navigate to DevelopersAPI keys
  2. Copy the Secret key (starts with sk_)
  3. For testing, use the test mode keys (starts with sk_test_)
STRIPE_SECRET_KEY="sk_test_..."

Configure Webhooks

  1. Go to DevelopersWebhooks
  2. Click Add endpoint
  3. Set the endpoint URL: https://your-domain.com/api/webhooks/stripe
  4. Select events to listen for:
    • checkout.session.completed
    • checkout.session.expired
    • payment_intent.payment_failed
  5. 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/stripe

This will give you a local webhook secret to use.

Pricing Tiers

SavvySolve offers three pricing tiers based on session complexity:

TierNameBase PriceIncluded MinutesOverage Rate
quickQuick Assist$69.0020 min$3.00/min
standardStandard Solve$129.0045 min$2.50/min
extendedDeep Dive$219.0090 min$2.00/min
lib/stripe/payments.ts
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:

lib/stripe/client.ts
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:

lib/stripe/payments.ts
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,
  };
}

When a session completes, we create a Stripe Payment Link:

lib/stripe/payments.ts
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:

server/routers/sessions.ts
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:

app/api/webhooks/stripe/route.ts
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:

components/session/PaymentStatus.tsx
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:

StatusDisplayAction
pendingClock icon, amberShow "Pay Now" button
paidCheck icon, greenShow receipt confirmation
failedAlert icon, redShow "Try Again" button
refundedRefresh icon, grayShow 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 NumberResult
4242 4242 4242 4242Success
4000 0000 0000 0002Card declined
4000 0000 0000 9995Insufficient 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.completed

Environment Variables

VariableDescriptionRequired
STRIPE_SECRET_KEYStripe API secret keyYes
STRIPE_WEBHOOK_SECRETWebhook signing secretYes
NEXT_PUBLIC_APP_URLApp base URL (for redirects)Yes

On this page