Add StreamPay payments and subscriptions to a Better Auth app.
Install the included integration skill:
npx skills add Genie-sa/better-auth-streampayThen ask your coding agent to add StreamPay to your Better Auth app.
The plugin can:
- create StreamPay consumers
- open hosted checkout
- show a billing portal
- manage subscriptions
- expose admin billing actions
- verify and handle signed webhooks
pnpm add better-auth-streampay @streamsdk/typescriptRequired versions:
better-auth ^1.5.0@streamsdk/typescript ^1.1.3zod ^3.24.0 || ^4.0.0
Add your StreamPay API key:
STREAMPAY_API_KEY=
STREAMPAY_WEBHOOK_SECRET=Create one SDK client and pass it to the plugin:
import StreamSDK from "@streamsdk/typescript";
import { betterAuth } from "better-auth";
import {
checkout,
portal,
streampay,
subscriptions,
webhooks,
} from "better-auth-streampay";
const streamPayClient = StreamSDK.init(process.env.STREAMPAY_API_KEY!);
export const auth = betterAuth({
plugins: [
streampay({
client: streamPayClient,
use: [
checkout(),
portal(),
subscriptions({
plans: [
{
name: "pro",
productId: "your-recurring-product-id",
priceInSmallestUnit: 9900,
billingInterval: "MONTH",
limits: { reports: true },
},
],
}),
webhooks({
secret: process.env.STREAMPAY_WEBHOOK_SECRET!,
}),
],
}),
],
});Only add the parts you use.
For a new Better Auth managed database:
npx auth@latest migrate --config path/to/auth.tsFor Drizzle or Prisma:
npx auth@latest generate --config path/to/auth.tsReview the generated schema, then use your normal migration process. The plugin declares the
schema but never changes your database at runtime. For an existing subscription table, backfill
seats to 1 in the same migration.
streampayConsumerId is unique. Before applying the generated unique index to an existing
database, resolve any duplicate non-null consumer IDs. Checkout fails closed when a consumer link
cannot be stored safely.
import { createAuthClient } from "better-auth/react";
import { streampayClient } from "better-auth-streampay/client";
export const authClient = createAuthClient({
plugins: [streampayClient()],
});Use the matching Better Auth client for Vue, Svelte, or Solid.
By default, the plugin creates a StreamPay consumer when the user first needs one. This keeps a StreamPay outage from blocking sign-up.
To create the consumer during sign-up:
streampay({
client: streamPayClient,
createConsumerOnSignUp: true,
use: [],
});To reuse an existing StreamPay consumer after a verified email match:
streampay({
client: streamPayClient,
createConsumerOnSignUp: true,
claimExistingConsumerBy: ["email"],
use: [],
});Only enable reuse when your app verifies the matching email or phone number.
checkout({
products: [
{ slug: "starter", productId: "product-id" },
],
successUrl: "/billing/success",
failureUrl: "/billing/failed",
authenticatedUsersOnly: true,
});Open checkout:
const { data } = await authClient.checkout({
slug: "starter",
redirect: false,
});
window.location.href = data.url;You can also pass StreamPay product IDs directly with products.
For a store, derive product IDs, quantities, coupons, expiry, and redirect URLs on the server:
import { APIError } from "better-auth/api";
checkout({
authenticatedUsersOnly: true,
resolveCheckout: async ({ user, body }) => {
const order = await loadAuthorizedOrder(user?.id, body.referenceId);
if (!order) {
throw new APIError("FORBIDDEN", {
code: "ORDER_NOT_AVAILABLE",
message: "This order is not available for checkout.",
});
}
return {
products: [{ productId: order.productId, quantity: order.quantity }],
successUrl: `https://shop.example.com/orders/${order.id}?checkout=success`,
failureUrl: `https://shop.example.com/orders/${order.id}?checkout=failed`,
maxNumberOfPayments: 1,
validUntil: order.validUntil,
metadata: { flow: "store" },
};
},
onCheckoutCreated: async ({ referenceId, paymentLinkId, payload }) => {
await saveOrderPaymentLink({ referenceId, paymentLinkId, payload });
},
});The client then sends only app-owned reference data:
await authClient.checkout({ referenceId: order.id, redirect: false });Configuring resolveCheckout automatically makes product, pricing, coupon, expiry, metadata, and
redirect URL fields server-only. Requests that include those fields are rejected; without a
resolver, the existing client-driven checkout behavior is unchanged.
Invalid values returned by resolveCheckout produce a generic 500 response. Validation details are
written to the server log without echoing the invalid values to the client.
If onCheckoutCreated throws, checkout returns an error and the plugin attempts to deactivate the
new payment link. Deactivation is best effort, so webhook handling should still reconcile unexpected
links. Relative success and failure URLs resolve against the auth server; use absolute URLs when the
storefront has a different origin.
portal() adds three signed-in user actions:
statesubscriptionsinvoices
const state = await authClient.consumer.state();
const subscriptions = await authClient.consumer.subscriptions.list();
const invoices = await authClient.consumer.invoices.list();subscriptions({
plans: [
{
name: "pro-monthly",
productId: "product-id",
priceInSmallestUnit: 9900,
currency: "SAR",
billingInterval: "MONTH",
billingIntervalCount: 1,
trialPeriodDays: 7,
group: "main",
seatBilling: {
default: 3,
minimum: 1,
maximum: 100,
},
limits: { projects: 50, reports: true },
},
],
onSubscriptionActivated: async ({ subscription, user }) => {
// Run app-specific work here.
},
});Plan names and product IDs must be unique. Prices use the smallest currency unit. For SAR,
9900 means 99.00 SAR.
priceInSmallestUnit is the price per seat. seatBilling controls billed quantity; limits
controls application access. Set customerEditable: true with explicit minimum and maximum bounds
to let customers change quantity in hosted checkout.
The plugin gives one trial per user and plan group by default. Use isTrialEligible when your
app needs a stricter or more flexible rule.
const { data } = await authClient.subscription.upgrade({
plan: "pro-monthly",
seats: 5,
});
window.location.href = data.url;On the success page:
await authClient.subscription.success({
query: { subscriptionId: data.subscriptionId },
});Then refresh the subscription data used by your UI.
await authClient.subscription.changePlan({
subscriptionId,
plan: "pro-yearly",
seats: 8, // optional; defaults to the current quantity
});
await authClient.subscription.updateSeats({
subscriptionId,
seats: 12,
});
await authClient.subscription.pendingChange.cancel({ subscriptionId });
await authClient.subscription.cancel({
subscriptionId,
cancelAtPeriodEnd: true,
});
await authClient.subscription.uncancel({ subscriptionId });StreamPay applies quantity and plan changes at period end. Read the active quantity from seats
and the scheduled quantity from pendingSeats. The older
authClient.subscription.changePlan.cancel() action remains available.
StreamPay cancels an active subscription at the end of its current period. Trial and inactive subscriptions are canceled at once.
const { data: freeze } = await authClient.subscription.freeze({
subscriptionId,
freezeStartDatetime: new Date().toISOString(),
freezeEndDatetime: null,
});
await authClient.subscription.unfreeze({ subscriptionId });
await authClient.subscription.freeze.cancel({
subscriptionId,
freezeId: freeze.id!,
});const current = await authClient.subscription.current({
query: { group: "main" },
});
const feature = await authClient.subscription.hasFeature({
query: { feature: "reports", group: "main" },
});
const projects = await authClient.subscription.checkLimit({
query: { feature: "projects", count: 4, group: "main" },
});checkLimit reads configured entitlements. Use current.data?.seats for licensed-member counts.
Pass group for grouped plans. Leave it out only for a plan without a group.
See the subscription data model for columns, lifecycle, and migration details.
By default, active, trialing, frozen, and past_due subscriptions can use plan
features. Change this with accessStatuses.
Every checkout, portal, subscription, and admin action is also available on auth.api. Types
come from Better Auth.
Browser calls follow the route path, such as authClient.subscription.cancel. Server calls use
the endpoint name, such as auth.api.cancelSubscription.
import { headers } from "next/headers";
const current = await auth.api.currentSubscription({
query: { group: "main" },
headers: await headers(),
});
await auth.api.cancelSubscription({
body: { subscriptionId, cancelAtPeriodEnd: true },
headers: await headers(),
});Pass the request headers so Better Auth can read the session.
Webhook delivery is not an auth.api action. It uses the raw HTTP body to verify the signature.
User actions use the signed-in user's ID by default.
To manage an organization or another app-owned reference, add authorizeReference:
subscriptions({
plans,
authorizeReference: async ({ user, referenceId, referenceType, action }) => {
return canManageBilling(user, referenceId, referenceType, action);
},
});Without this callback, cross-account actions return FORBIDDEN.
admin() adds billing actions for payments, subscriptions, freezes, consumers, invoices,
products, coupons, payment links, and webhook retries.
import { admin } from "better-auth-streampay";
admin({
adminRoles: ["admin", "billing"],
isAdmin: async (user) => user.email.endsWith("@example.com"),
});Calls use names such as:
auth.api.adminListPaymentsauth.api.adminGetSubscriptionauth.api.adminCreateProductauth.api.adminReplayWebhookEvent
The IDE shows the body, query, and response types for every action.
Make sure your Better Auth route accepts GET, POST, PATCH, PUT, and DELETE.
Register this URL in the StreamPay dashboard:
https://your-app.com/api/auth/streampay/webhooks
Then add the handlers you need:
webhooks({
secret: process.env.STREAMPAY_WEBHOOK_SECRET!,
onPaymentSucceeded: async (event) => {},
onPaymentFailed: async (event) => {},
onSubscriptionActivated: async (event) => {},
onSubscriptionCanceled: async (event) => {},
onPayload: async (event) => {},
});The plugin:
- checks the webhook signature
- rejects old signatures
- deduplicates subscription sync and lifecycle callbacks
- retries temporary failures
- stores failed subscription events for admin replay
The StreamPay SDK does not export webhook payload types. This package provides checked event types based on StreamPay's documented payloads.
To rotate a secret:
webhooks({
secret: [
process.env.STREAMPAY_WEBHOOK_SECRET!,
process.env.STREAMPAY_WEBHOOK_SECRET_OLD!,
],
});Remove the old secret after StreamPay uses the new one.
StreamPay owns:
- charges
- invoices
- subscription state
- trials
- renewal attempts
- cancellation timing
The plugin owns:
- Better Auth access checks
- local subscription rows
- plan features and limits
- webhook checks and retries
- checkout recovery
Do not edit subscription rows by hand. Let provider responses and webhooks update them.
Errors use stable codes:
import { $ERROR_CODES } from "better-auth-streampay";
if (error.code === $ERROR_CODES.SUBSCRIPTION_ALREADY_ACTIVE.code) {
// Show the current plan.
}Common codes include:
VALIDATION_ERRORFORBIDDENNOT_FOUNDSUBSCRIPTION_ALREADY_ACTIVESUBSCRIPTION_INVALID_STATESUBSCRIPTION_PLAN_CHANGE_ALREADY_SCHEDULEDWEBHOOK_REPLAY_IN_PROGRESS
import {
StreamPayAmount,
checkLimit,
findConsumerByExternalId,
formatStreamPayError,
hasFeature,
parseStreamPayError,
verifyWebhook,
} from "better-auth-streampay";MIT