mcpField
S
subscriptions-best-practices

subscriptions-best-practices

github.com/getpaykitDevelopment155 installsUpdated 29w ago
Repository

About subscriptions-best-practices

A PayKit skill for developers implementing subscription flows. It explains state-aware plan changes, checkout, lifecycle updates, and customer billing management across server and client usage.

Key features

  1. 01Handle transitions based on current state
  2. 02Support upgrades, downgrades, cancellation, and resumption
  3. 03Show server and client usage
  4. 04Cover subscription state and checkout redirects
  5. 05Explain webhooks and customer portal

Use cases

  1. 01Start or change a user's subscription plan
  2. 02Sync subscription state with an application
  3. 03Send users through payment checkout
  4. 04Let customers manage payment methods and invoices

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/getpaykit/skills --skill subscriptions-best-practices

After installing, type/subscriptions-best-practicesin Claude Code to confirm it loaded.

Details

From the project README

Subscriptions

PayKit uses a single subscribe() method for all subscription transitions.

subscribe()

const result = await paykit.subscribe({
  customerId: "user_123",
  planId: "pro",
  successUrl: "https://example.com/billing?success=true",
  cancelUrl: "https://example.com/billing",
})
Parameters
ParameterRequiredDescription
customerIdYesYour app's user ID
planIdYesTarget plan ID (type-safe)
successUrlYesRedirect after successful checkout
cancelUrlNoRedirect if user cancels checkout
forceCheckoutNoAlways redirect to checkout, even if payment method exists
Return Value
interface SubscribeResult {
  paymentUrl: string | null
  invoice?: {
    providerInvoiceId: string
    hostedUrl: string | null
    currency: string
    totalAmount: number
    status: string | null
  }
  requiredAction?: {
    type: string
    paymentIntentId?: string
    clientSecret?: string
  } | null
}

Transition Semantics

subscribe() determines the correct action based on the customer's current state:

Current StateTargetBehavior
No subscriptionFree planActivates immediately
No subscriptionPaid plan, no payment methodReturns paymentUrl for Stripe Checkout
No subscriptionPaid plan, has payment methodCreates subscription directly
On free planPaid planCheckout or direct subscription
On paid planHigher-priced plan (same group)Upgrade: switches immediately
On paid planLower-priced plan (same group)Downgrade: scheduled for period end
On paid planFree plan (same group)Cancel: scheduled for period end
Pending cancelSame planResume: clears cancellation
Pending downgradeCurrent planResume: cancels the scheduled change
On current planSame plan, no pending changesNo-op

Server-Side Usage

// New subscription (may require checkout)
const { paymentUrl } = await paykit.subscribe({
  customerId: "user_123",
  planId: "pro",
  successUrl: "/billing?success=true",
})

if (paymentUrl) {
  // Redirect user to Stripe Checkout
  redirect(paymentUrl)
}

// Upgrade (immediate, no checkout needed)
await paykit.subscribe({
  customerId: "user_123",
  planId: "enterprise",
  successUrl: "/billing",
})

// Downgrade (scheduled for period end)
await paykit.subscribe({
  customerId: "user_123",
  planId: "free",
  successUrl: "/billing",
})

// Resume a pending cancellation
await paykit.subscribe({
  customerId: "user_123",
  planId: "pro", // same plan they're on
  successUrl: "/billing",
})

Client-Side Usage

The client SDK omits customerId because it is resolved via identify.

import { paykitClient } from "@/lib/paykit-client"

// Subscribe to a plan
await paykitClient.subscribe({
  planId: "pro",
  successUrl: `${window.location.origin}/billing?success=true`,
  cancelUrl: `${window.location.origin}/billing`,
})

// Open Stripe Customer Portal (manage payment methods, invoices)
await paykitClient.customerPortal({
  returnUrl: window.location.href,
})

Reading Subscription State

const customer = await paykit.getCustomer({ id: "user_123" })

for (const sub of customer.subscriptions) {
  console.log(sub.planId)              // "pro"
  console.log(sub.status)             // "active", "canceled", etc.
  console.log(sub.cancelAtPeriodEnd)  // true if pending cancellation
  console.log(sub.currentPeriodEnd)   // when the current period ends
}

Handling Checkout Redirects

When subscribe() returns a paymentUrl, redirect the user to complete payment:

Server component / API route:

import { redirect } from "next/navigation"

const { paymentUrl } = await paykit.subscribe({
  customerId: userId,
  planId: "pro",
  successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`,
})

if (paymentUrl) {
  redirect(paymentUrl)
}

Client-side:

const result = await paykitClient.subscribe({
  planId: "pro",
  successUrl: `${window.location.origin}/billing?success=true`,
})

The client SDK handles redirects automatically when a paymentUrl is returned.


Webhooks

Stripe sends webhooks for subscription lifecycle events. PayKit processes them automatically via the route handler.

Ensure your webhook endpoint is configured in Stripe to point to {your-domain}/paykit/api/webhook.

To react to subscription changes in your app:

export const paykit = createPayKit({
  // ...
  on: {
    "customer.updated": ({ payload }) => {
      // Sync subscription state to your app
      await updateUserRole(payload.customerId, payload.subscriptions)
    },
  },
})

Stripe Customer Portal

Let customers manage their billing (update payment method, view invoices, cancel):

// Server-side
const { url } = await paykit.customerPortal({
  customerId: "user_123",
  returnUrl: "https://example.com/billing",
})

// Client-side
await paykitClient.customerPortal({
  returnUrl: window.location.href,
})

Comments

Markdown supported