Loading...
Loading...
Implement secure payments using Clerk Billing and Stripe without ever touching card data. Use this skill when you need to set up subscription payments, handle webhooks, implement payment gating, understand PCI-DSS compliance, or integrate Stripe Checkout. Triggers include "payment", "Stripe", "Clerk Billing", "subscription", "PCI-DSS", "credit card", "payment security", "checkout", "webhook", "billing".
npx skill4agent add harperaa/secure-claude-skills payment-security-clerk-billing-stripePricingTablecomponents/custom-clerk-pricing.tsxapp/dashboard/payment-gated/page.tsxconvex/http.ts# .env.local
# Clerk Billing
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Stripe (automatically configured by Clerk Billing)
# No manual Stripe keys needed!
# Webhook signing secret (from Clerk)
CLERK_WEBHOOK_SECRET=whsec_...// components/custom-clerk-pricing.tsx
'use client';
import { PricingTable } from '@clerk/clerk-react';
export function CustomClerkPricing() {
return (
<div className="pricing-container">
<h1>Choose Your Plan</h1>
<PricingTable
appearance={{
elements: {
card: 'border rounded-lg p-6',
cardActive: 'border-blue-500',
button: 'bg-blue-600 hover:bg-blue-700 text-white',
}
}}
/>
</div>
);
}// app/api/premium-feature/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@clerk/nextjs/server';
import { handleUnauthorizedError, handleForbiddenError } from '@/lib/errorHandler';
export async function GET(request: NextRequest) {
const { userId, sessionClaims } = await auth();
if (!userId) {
return handleUnauthorizedError();
}
// Check subscription status from Clerk
const plan = sessionClaims?.metadata?.plan as string;
if (plan === 'free_user') {
return handleForbiddenError('Premium subscription required');
}
// User has paid subscription
return NextResponse.json({
message: 'Welcome to premium feature!',
plan: plan
});
}'use client';
import { Protect } from '@clerk/nextjs';
import Link from 'next/link';
export function PremiumFeature() {
return (
<Protect
condition={(has) => !has({ plan: "free_user" })}
fallback={<UpgradePrompt />}
>
<div>
{/* Premium feature content */}
<h2>Premium Feature</h2>
<p>This content is only visible to paid subscribers</p>
</div>
</Protect>
);
}
function UpgradePrompt() {
return (
<div className="upgrade-prompt">
<h3>Upgrade to Premium</h3>
<p>This feature is available on our paid plans</p>
<Link href="/pricing">
<button>View Pricing</button>
</Link>
</div>
);
}// app/dashboard/payment-gated/page.tsx
'use client';
import { Protect } from '@clerk/nextjs';
import { CustomClerkPricing } from '@/components/custom-clerk-pricing';
export default function PaymentGatedPage() {
return (
<div>
<Protect
condition={(has) => !has({ plan: "free_user" })}
fallback={
<div className="upgrade-required">
<h1>Premium Access Required</h1>
<p>Subscribe to access this page</p>
<CustomClerkPricing />
</div>
}
>
<div className="premium-content">
<h1>Premium Dashboard</h1>
<p>Welcome to the premium features!</p>
{/* Premium features here */}
</div>
</Protect>
</div>
);
}// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
throw new Error('Missing CLERK_WEBHOOK_SECRET');
}
// Get webhook headers
const headerPayload = headers();
const svix_id = headerPayload.get("svix-id");
const svix_timestamp = headerPayload.get("svix-timestamp");
const svix_signature = headerPayload.get("svix-signature");
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Missing svix headers', { status: 400 });
}
const payload = await request.json();
const body = JSON.stringify(payload);
// Verify webhook signature
const wh = new Webhook(WEBHOOK_SECRET);
let evt: any;
try {
evt = wh.verify(body, {
"svix-id": svix_id,
"svix-timestamp": svix_timestamp,
"svix-signature": svix_signature,
});
} catch (err) {
console.error('Webhook verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
const { id, type, data } = evt;
// Handle subscription events
switch (type) {
case 'subscription.created':
await handleSubscriptionCreated(data);
break;
case 'subscription.updated':
await handleSubscriptionUpdated(data);
break;
case 'subscription.deleted':
await handleSubscriptionDeleted(data);
break;
case 'user.created':
await handleUserCreated(data);
break;
case 'user.updated':
await handleUserUpdated(data);
break;
}
return new Response('', { status: 200 });
}
async function handleSubscriptionCreated(data: any) {
const { user_id, plan, stripe_customer_id } = data;
// Store subscription in database
await db.subscriptions.create({
userId: user_id,
plan: plan,
stripeCustomerId: stripe_customer_id,
status: 'active',
createdAt: Date.now()
});
// Update user metadata
await db.users.update(
{ clerkId: user_id },
{ plan: plan, updatedAt: Date.now() }
);
}
async function handleSubscriptionUpdated(data: any) {
const { user_id, plan, status } = data;
await db.subscriptions.update(
{ userId: user_id },
{
plan: plan,
status: status,
updatedAt: Date.now()
}
);
// Update user plan
await db.users.update(
{ clerkId: user_id },
{ plan: plan }
);
}
async function handleSubscriptionDeleted(data: any) {
const { user_id } = data;
await db.subscriptions.update(
{ userId: user_id },
{
status: 'cancelled',
cancelledAt: Date.now()
}
);
// Downgrade to free
await db.users.update(
{ clerkId: user_id },
{ plan: 'free_user' }
);
}// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { Webhook } from "svix";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/clerk-webhook",
method: "POST",
handler: httpAction(async (ctx, request) => {
const payload = await request.text();
const headers = request.headers;
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET!);
let evt: any;
try {
evt = wh.verify(payload, {
"svix-id": headers.get("svix-id")!,
"svix-timestamp": headers.get("svix-timestamp")!,
"svix-signature": headers.get("svix-signature")!,
});
} catch (err) {
return new Response("Invalid signature", { status: 400 });
}
const { type, data } = evt;
switch (type) {
case "subscription.created":
await ctx.runMutation(internal.subscriptions.create, {
userId: data.user_id,
plan: data.plan,
stripeCustomerId: data.stripe_customer_id,
});
break;
case "subscription.updated":
await ctx.runMutation(internal.subscriptions.update, {
userId: data.user_id,
plan: data.plan,
status: data.status,
});
break;
case "subscription.deleted":
await ctx.runMutation(internal.subscriptions.cancel, {
userId: data.user_id,
});
break;
}
return new Response("", { status: 200 });
}),
});
export default http;Success: 4242 4242 4242 4242
Decline: 4000 0000 0000 0002
3D Secure: 4000 0025 0000 3155
Insufficient funds: 4000 0000 0000 9995
CVV: Any 3 digits
Expiry: Any future date
ZIP: Any 5 digits/pricing4242 4242 4242 4242# Use Stripe CLI to forward webhooks to local
stripe listen --forward-to localhost:3000/api/webhooks/clerk
# Trigger test subscription event
stripe trigger subscription.createdsubscription.payment_failed// In webhook handler
case 'subscription.payment_failed':
await handlePaymentFailed(data);
break;
async function handlePaymentFailed(data: any) {
const { user_id, attempt_count } = data;
// Update subscription status
await db.subscriptions.update(
{ userId: user_id },
{
status: 'past_due',
lastPaymentFailed: Date.now(),
failedAttempts: attempt_count
}
);
// Send email to user
await sendEmail({
to: getUserEmail(user_id),
subject: 'Payment Failed',
template: 'payment-failed',
data: {
attemptCount: attempt_count,
retryDate: calculateRetryDate(attempt_count)
}
});
}// Bad - no signature verification
export async function POST(request: NextRequest) {
const data = await request.json();
// Process data directly - could be forged!
}// Good - signature verified by Svix
const wh = new Webhook(WEBHOOK_SECRET);
const evt = wh.verify(body, headers); // Throws if invalid
// Now safe to process// Bad - PCI-DSS violation
await db.payments.create({
userId,
cardNumber: '4242424242424242', // ❌ NEVER DO THIS
cvv: '123', // ❌ NEVER DO THIS
expiry: '12/25' // ❌ NEVER DO THIS
});// Good - no card data
await db.subscriptions.create({
userId,
stripeCustomerId: 'cus_123', // ✅ Stripe internal ID
stripeSubscriptionId: 'sub_456', // ✅ Stripe internal ID
plan: 'pro',
status: 'active'
});// Bad - can be bypassed
'use client';
const { user } = useUser();
if (user?.publicMetadata?.plan === 'pro') {
// Show premium feature - attacker can fake this
}// Good - secure
export async function GET(request: NextRequest) {
const { sessionClaims } = await auth();
const plan = sessionClaims?.metadata?.plan;
if (plan !== 'pro') {
return handleForbiddenError();
}
// Premium feature access
}// Track processed webhook IDs
const processedWebhooks = new Set<string>();
export async function POST(request: NextRequest) {
const evt = await verifyWebhook(request);
const { id } = evt;
// Check if already processed
if (processedWebhooks.has(id)) {
return new Response('Already processed', { status: 200 });
}
// Process webhook
await handleWebhook(evt);
// Mark as processed
processedWebhooks.add(id);
return new Response('', { status: 200 });
}auth-securityrate-limitingerror-handlingsecurity-testing