Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/revokslab/shipfree/llms.txt

Use this file to discover all available pages before exploring further.

The Payment Webhooks endpoint processes events from payment providers (Stripe, Polar, Lemon Squeezy) to keep your database synchronized with payment status changes.

Webhook Endpoint

POST /api/webhooks/payments Receives and processes webhook events from payment providers.

Implementation Overview

From src/app/api/webhooks/payments/route.ts:10-225, the webhook handler:
  1. Verifies webhook signature based on active provider
  2. Parses the event into a standardized format
  3. Processes the event via payment adapter
  4. Updates database with customer, subscription, and payment data

Webhook Flow

export async function POST(req: Request) {
  const rawBody = await req.text()
  const headerList = await headers()
  const adapter = getPaymentAdapter()

  // 1. Get signature based on provider
  let signature = ''
  switch (adapter.provider) {
    case 'stripe':
      signature = headerList.get('stripe-signature') || ''
      break
    case 'polar':
      signature = headerList.get('polar-webhook-signature') || ''
      break
    case 'lemonsqueezy':
      signature = headerList.get('x-signature') || ''
      break
  }

  // 2. Validate signature
  const isValid = await adapter.validateWebhook(rawBody, signature)
  if (!isValid) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  // 3. Parse and process event
  const event = parseWebhookEvent(rawBody, adapter.provider)
  const result = await adapter.processWebhook(event)

  // 4. Update database
  await updateDatabase(result)

  return NextResponse.json({ received: true })
}

Signature Verification

All webhook requests are verified using provider-specific signatures to ensure authenticity.

Signature Headers by Provider

// Header: stripe-signature
const signature = headerList.get('stripe-signature')

// Verified using STRIPE_WEBHOOK_SECRET
await adapter.validateWebhook(rawBody, signature)
From src/app/api/webhooks/payments/route.ts:22-42.

Supported Events

Customer Events

customer.created
event
New customer createdUpdates: Creates new record in customer table
customer.updated
event
Customer information updatedUpdates: Updates email and metadata in customer table
customer.deleted
event
Customer deletedUpdates: Can cascade delete related records

Subscription Events

subscription.created
event
New subscription createdUpdates: Creates record in subscription table with status active or trialing
subscription.updated
event
Subscription modified (plan change, status change, etc.)Updates: Updates subscription status, plan, billing period, amounts
subscription.canceled
event
Subscription canceledUpdates: Sets status to canceled, records canceledAt timestamp
subscription.deleted
event
Subscription permanently deletedUpdates: Marks subscription as deleted

Payment Events

invoice.payment_succeeded
event
Payment successfulUpdates: Creates payment record with status succeeded
invoice.payment_failed
event
Payment failedUpdates: Creates or updates payment record with status failed, may update subscription to past_due
checkout.session.completed
event
Checkout session completedUpdates: Creates customer, subscription, and initial payment records
order.paid
event
One-time payment completed (Lemon Squeezy)Updates: Creates payment record for one-time purchase
From src/lib/payments/types.ts:109-120.

Database Updates

Customer Table

From src/app/api/webhooks/payments/route.ts:96-119:
if (result.customer) {
  const existingCustomer = await db.query.customer.findFirst({
    where: eq(customer.providerCustomerId, result.customer.providerCustomerId)
  })

  if (existingCustomer) {
    // Update existing customer
    await db.update(customer)
      .set({
        email: result.customer.email,
        updatedAt: new Date()
      })
      .where(eq(customer.id, existingCustomer.id))
  } else if (result.customer.userId) {
    // Create new customer
    await db.insert(customer).values({
      id: result.customer.id,
      userId: result.customer.userId,
      provider: result.customer.provider,
      providerCustomerId: result.customer.providerCustomerId,
      email: result.customer.email
    })
  }
}

Subscription Table

From src/app/api/webhooks/payments/route.ts:122-181:
if (result.subscription) {
  const existingSub = await db.query.subscription.findFirst({
    where: eq(subscription.providerSubscriptionId, result.subscription.providerSubscriptionId)
  })

  if (existingSub) {
    // Update existing subscription
    await db.update(subscription).set({
      status: result.subscription.status,
      plan: result.subscription.plan,
      interval: result.subscription.interval,
      amount: result.subscription.amount?.toString(),
      currency: result.subscription.currency,
      currentPeriodStart: result.subscription.currentPeriodStart,
      currentPeriodEnd: result.subscription.currentPeriodEnd,
      cancelAtPeriodEnd: result.subscription.cancelAtPeriodEnd,
      canceledAt: result.subscription.canceledAt,
      trialStart: result.subscription.trialStart,
      trialEnd: result.subscription.trialEnd,
      updatedAt: new Date()
    }).where(eq(subscription.id, existingSub.id))
  } else if (result.subscription.userId) {
    // Create new subscription
    await db.insert(subscription).values({ ... })
  }
}

Payment Table

From src/app/api/webhooks/payments/route.ts:185-217:
if (result.payment) {
  const existingPayment = await db.query.payment.findFirst({
    where: eq(payment.providerPaymentId, result.payment.providerPaymentId)
  })

  if (existingPayment) {
    // Update payment status
    await db.update(payment)
      .set({ status: result.payment.status, updatedAt: new Date() })
      .where(eq(payment.id, existingPayment.id))
  } else if (result.payment.userId) {
    // Create new payment
    await db.insert(payment).values({
      id: result.payment.id,
      userId: result.payment.userId,
      customerId: result.payment.customerId,
      subscriptionId: result.payment.subscriptionId,
      provider: result.payment.provider,
      providerPaymentId: result.payment.providerPaymentId,
      type: result.payment.type,
      status: result.payment.status,
      amount: result.payment.amount.toString(),
      currency: result.payment.currency,
      description: result.payment.description
    })
  }
}

Webhook Event Structure

From src/lib/payments/types.ts:125-130:
export interface WebhookEvent {
  type: WebhookEventType
  provider: PaymentProvider
  data: any // Provider-specific event data
  rawEvent: any // Raw provider event
}

Provider-Specific Parsing

From src/app/api/webhooks/payments/route.ts:56-80:
const parsedBody = JSON.parse(rawBody)

event = {
  type: parsedBody.type,
  provider: 'stripe',
  data: parsedBody.data.object,
  rawEvent: parsedBody
}

Setup Instructions

1

Configure Webhook URL

Add webhook endpoint to your payment provider:
https://yourapp.com/api/webhooks/payments
2

Set Webhook Secret

Add the webhook signing secret to your environment variables:
STRIPE_WEBHOOK_SECRET=whsec_...
3

Select Events

Configure which events to receive (recommended):
  • customer.created
  • customer.updated
  • subscription.created
  • subscription.updated
  • subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed
  • checkout.session.completed
4

Test Webhook

Use provider’s test mode to verify webhook is working:
stripe trigger checkout.session.completed

Response Format

Success Response

{
  "received": true
}
Status: 200 OK

Error Responses

400
Bad Request
Missing or invalid signature
{ "error": "Missing signature" }
{ "error": "Invalid signature" }
{ "error": "Invalid JSON" }
500
Internal Server Error
Webhook processing error
{
  "error": "Failed to process subscription.updated event"
}

Idempotency

The webhook handler uses providerCustomerId, providerSubscriptionId, and providerPaymentId to ensure idempotency. Duplicate events are handled gracefully by updating existing records.
From src/app/api/webhooks/payments/route.ts:97-99, 123-125, 186-188:
// Check if record exists before creating
const existing = await db.query.customer.findFirst({
  where: eq(customer.providerCustomerId, providerCustomerId)
})

if (existing) {
  // Update instead of creating duplicate
  await db.update(customer).set({ ... })
}

Debugging

Webhook errors are logged to console:
console.error('Webhook processing error:', result.error)
console.error('Webhook handler error:', error)
From src/app/api/webhooks/payments/route.ts:89, 222.
Enable detailed logging in development to troubleshoot webhook issues. Check your payment provider’s dashboard for webhook delivery status and retry attempts.

Database Schema

View customer, subscription, and payment tables

Checkout API

Create checkout sessions

Payment Types

TypeScript type definitions

Build docs developers (and LLMs) love