vantezzen/pay
Core concepts

External auth

Attach credits to your own user ids instead of a browser code.

External auth mode is for apps that already have user accounts. Instead of a browser recovery code stored in localStorage, your server tells vantezzen/pay which user owns a wallet, keyed by your own externalUserId.

When to use it

Use external auth when:

  • Your app already requires login.
  • Users expect credits to follow their account across devices.
  • You want wallet creation to happen server-side, not in the browser.
  • You need support tooling that looks users up by your own user id.

If your app has no accounts, use credit codes instead - they need zero server code.

How it works

The mental model is one call at the boundary of your app:

  1. Authenticate the user with whatever auth you already use.
  2. On your server, call vantezzen/pay with your secret key (pay_sk_…) and the user's id. vantezzen/pay gets-or-creates that user's wallet (idempotent).
  3. Optionally grant credits (promos, migrations, support) server-side.
  4. Read balances / start checkout on the server, or expose a small same-origin bridge so the React components can do it for the signed-in user.

The secret key must stay on the server. Never ship a pay_sk_… key to the browser - that's what the publishable key and the credit-code client are for.

The server client

Install the zero-dependency server helper from the registry:

npx shadcn@latest add https://pay.vantezzen.io/r/pay-server.json
// lib/pay/server.ts is added for you; create one instance to reuse.
import { createPayServerClient } from "@/lib/pay/server";

export const payServer = createPayServerClient({
  baseUrl: process.env.NEXT_PUBLIC_PAY_URL!,
  secretKey: process.env.PAY_SECRET_KEY!, // pay_sk_… - server only
});

It exposes these server-side methods:

  • getOrCreateWallet(externalUserId) → the user's wallet + balances.
  • deduct({ externalUserId, amount, idempotencyKey, productId?, meta? }) → spend credits when the user runs paid work.
  • grantCredits({ externalUserId | code, productId?, amount, idempotencyKey? }) → add credits (promos, support, migrations).
  • createCheckout(priceId, { externalUserId, successUrl? }) → a provider checkout URL that credits this user's wallet when paid.
  • createPortalUrl(externalUserId, { returnUrl? }) → a provider portal URL for billing management.

Always pass a stable idempotencyKey (job id, request id) on deductions and grants so retries apply exactly once. For every server-client method and field, see createPayServerClient. For raw HTTP endpoints, see Server-side users.

Examples

better-auth

// app/api/wallet/route.ts
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { payServer } from "@/lib/pay/server";

export async function GET() {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) return new Response("Unauthorized", { status: 401 });

  const wallet = await payServer.getOrCreateWallet(session.user.id);
  return Response.json(wallet);
}

Next.js route handler (any auth)

Works with any auth that gives you a stable user id on the server - a route handler or a server action:

// app/api/credits/grant/route.ts
import { getUserId } from "@/lib/session"; // your own helper
import { payServer } from "@/lib/pay/server";

export async function POST() {
  const userId = await getUserId();
  if (!userId) return new Response("Unauthorized", { status: 401 });

  const result = await payServer.grantCredits({
    externalUserId: userId,
    amount: 100,
    idempotencyKey: `welcome-${userId}`, // one welcome grant per user
  });
  return Response.json(result);
}

Clerk / Auth.js

The pattern is identical - read the user id from your provider's server helper:

// Clerk
import { auth } from "@clerk/nextjs/server";
import { payServer } from "@/lib/pay/server";

export async function GET() {
  const { userId } = await auth();
  if (!userId) return new Response("Unauthorized", { status: 401 });
  return Response.json(await payServer.getOrCreateWallet(userId));
}
// Auth.js (NextAuth)
import { auth } from "@/auth";
import { payServer } from "@/lib/pay/server";

export async function GET() {
  const session = await auth();
  if (!session?.user?.id) return new Response("Unauthorized", { status: 401 });
  return Response.json(await payServer.getOrCreateWallet(session.user.id));
}

The full loop, server-side

A complete paid feature in external-auth mode is three calls, all with the server client:

// Somewhere in your server code - session already verified.
import { payServer } from "@/lib/pay/server";
import { PayError } from "@/lib/pay/server";

export async function renderVideo(userId: string, jobId: string) {
  try {
    // 1. Charge for the work (creates the wallet lazily via getOrCreateWallet
    //    on signup, or grant a welcome balance first).
    await payServer.deduct({
      externalUserId: userId,
      amount: 10,
      idempotencyKey: jobId, // retries never double-charge
    });
  } catch (err) {
    if (err instanceof PayError && err.isInsufficientCredits) {
      // 2. Out of credits → send the user to checkout.
      const { url } = await payServer.createCheckout("price_...", {
        externalUserId: userId,
        successUrl: "https://your-app.com/billing?status=success",
      });
      return { needsPayment: url };
    }
    throw err;
  }

  // 3. Run the paid work.
  return startRenderJob(jobId);
}

The checkout webhook credits the same user's wallet - no code handling. Render balances in your own UI from getOrCreateWallet(userId).balances.

React components with external auth

The React components can run in external-auth mode, but they still must not see your pay_sk_... key. Put a tiny authenticated bridge in your app, backed by the server client:

<PayProvider
  baseUrl={process.env.NEXT_PUBLIC_PAY_URL!}
  publishableKey={process.env.NEXT_PUBLIC_PAY_KEY!}
  mode="external_auth"
  externalUserId={user.id}
  externalApiBasePath="/api/pay"
>
  {children}
</PayProvider>

Then expose same-origin routes such as:

app/api/pay/wallet/route.ts
import { payServer } from "@/lib/pay/server";
import { requireUser } from "@/lib/auth";

export async function POST() {
  const user = await requireUser();
  return Response.json(await payServer.getOrCreateWallet(user.id));
}
app/api/pay/checkout/route.ts
import { payServer } from "@/lib/pay/server";
import { requireUser } from "@/lib/auth";

export async function POST(req: Request) {
  const user = await requireUser();
  const body = await req.json();
  return Response.json(
    await payServer.createCheckout(body.priceId, {
      externalUserId: user.id,
      successUrl: body.successUrl,
      cancelUrl: body.cancelUrl,
      customerEmail: user.email,
      allowPromotionCodes: body.allowPromotionCodes,
      discountCode: body.discountCode,
      idempotencyKey: body.idempotencyKey,
    }),
  );
}
app/api/pay/portal/route.ts
import { payServer } from "@/lib/pay/server";
import { requireUser } from "@/lib/auth";

export async function POST(req: Request) {
  const user = await requireUser();
  const { returnUrl } = await req.json();
  const url = await payServer.createPortalUrl(user.id, { returnUrl });
  return Response.json({ url });
}

The bridge should ignore or verify any externalUserId sent by the browser and use the authenticated session as the source of truth. Add a /api/pay/deduct route only when client-triggered deductions are appropriate; server-side paid work should deduct directly in the route or job that performs the work. Forward the optional checkout fields shown above unchanged: allowPromotionCodes, discountCode, and a stable idempotencyKey are part of the browser client's checkout request.