Reddot UI Library

Docs
Recurly Payment Integration

Recurly Payment Integration

Complete Recurly integration with client, webhooks, and checkout UI for Next.js applications

Recurly is a subscription management and recurring billing platform. This integration provides a complete payment solution for Next.js applications, including client-side tokenization, server-side billing, webhook handling, and React components.

What's Included

This integration is split into three modular blocks:

  1. recurly-core: Backend services (client, webhooks, API handlers)
  2. recurly-hooks: React hooks for Recurly.js integration
  3. recurly-checkout: Ready-to-use checkout UI components

Installation

Backend Only

If you only need backend functionality (creating accounts, handling webhooks):

$npx shadcn@latest add https://reddot.dottools.xyz/r/recurly-core.json

For a complete payment flow with UI components:

npx shadcn@latest add https://reddot.dottools.xyz/r/recurly-core.json
npx shadcn@latest add https://reddot.dottools.xyz/r/recurly-hooks.json
npx shadcn@latest add https://reddot.dottools.xyz/r/recurly-checkout.json

Configuration

Add the following environment variables to your .env.local:

# Recurly Public Key (client-side)
NEXT_PUBLIC_RECURLY_PUBLIC_KEY=ewr1-xxxxxxxxxxxxx
 
# Recurly API Key (server-side)
NEXT_SSR_RECURLY_API_KEY=xxxxxxxxxxxxx
 
# Recurly Webhook Signing Key (server-side)
NEXT_SSR_RECURLY_WEBHOOK_KEY=xxxxxxxxxxxxx

You can find these credentials in your Recurly Dashboard:

  • Public Key: Developers > Credentials > Public Key
  • API Key: Developers > Credentials > Private API Key
  • Webhook Key: Configuration > Webhooks > Signing Keys

Structure

recurly-core

lib/recurly/
├── client.ts                 # Recurly client (server-side)
├── types.ts                  # TypeScript types
├── schemas.ts                # Zod validation schemas
├── actions/
│   └── create-account.ts     # Server Action for account creation
├── api/
│   └── fetch-plans.ts        # Fetch Recurly plans
└── webhook/
    ├── types.ts              # Webhook payload types
    ├── verify-signature.ts   # Signature verification
    └── handlers/
        ├── index.ts          # Main webhook router
        ├── account.ts        # Account event handlers
        ├── subscription.ts   # Subscription event handlers
        ├── invoice.ts        # Invoice event handlers
        └── payment.ts        # Payment event handlers

recurly-hooks

hooks/recurly/
├── use-recurly-script.tsx     # Loads Recurly.js script
├── use-recurly.tsx            # Initializes Recurly instance
└── use-recurly-elements.tsx   # Tokenization helper

recurly-checkout

components/recurly-checkout/
├── checkout.tsx               # Complete checkout component
├── payment-form.tsx           # Payment form with card elements
└── elements/
    ├── card-number.tsx        # Card number input
    ├── card-cvc.tsx           # CVC input
    ├── card-month.tsx         # Expiry month input
    └── card-year.tsx          # Expiry year input

Usage

Creating a Subscription (Backend)

'use server';
 
import { createRecurlyAccount } from '@/lib/recurly/actions/create-account';
 
export async function handleCheckout(formData: FormData) {
  const result = await createRecurlyAccount({
    token: formData.get('recurly_token') as string,
    email: formData.get('email') as string,
    firstName: formData.get('first_name') as string,
    lastName: formData.get('last_name') as string,
    currency: 'CHF',
  });
 
  if (result.success) {
    console.log('Account created:', result.accountId);
    console.log('Subscription created:', result.subscriptionId);
  } else {
    console.error('Payment failed:', result.error);
  }
 
  return result;
}

Fetching Plans

import { fetchRecurlyPlans } from '@/lib/recurly/api/fetch-plans';
 
export async function getPlans() {
  try {
    const plans = await fetchRecurlyPlans();
    return plans;
  } catch (error) {
    console.error('Failed to fetch plans:', error);
    return [];
  }
}

Checkout UI Component

'use client';
 
import { RecurlyCheckout } from '@/components/recurly-checkout/checkout';
import { useRouter } from 'next/navigation';
 
export function CheckoutPage() {
  const router = useRouter();
 
  return (
    <RecurlyCheckout
      publicKey={process.env.NEXT_PUBLIC_RECURLY_PUBLIC_KEY!}
      onSuccess={(result) => {
        console.log('Payment successful!', result);
        router.push('/checkout/success');
      }}
      onError={(error) => {
        console.error('Payment failed:', error);
      }}
    >
      <button type="submit" className="w-full mt-4 px-4 py-2 bg-blue-600 text-white rounded">
        Complete Purchase
      </button>
    </RecurlyCheckout>
  );
}

Custom Payment Form

'use client';
 
import { useRecurlyScript } from '@/hooks/recurly/use-recurly-script';
import { useRecurly } from '@/hooks/recurly/use-recurly';
import { RecurlyPaymentForm } from '@/components/recurly-checkout/payment-form';
 
export function CustomCheckout() {
  const { isLoaded, isLoading, error: scriptError } = useRecurlyScript();
  const { elements, isReady } = useRecurly(process.env.NEXT_PUBLIC_RECURLY_PUBLIC_KEY!);
 
  if (isLoading) return <div>Loading...</div>;
  if (scriptError) return <div>Error loading payment system</div>;
  if (!isReady || !elements) return <div>Initializing...</div>;
 
  const handleSubmit = async (formData: FormData) => {
    // Handle form submission
    console.log('Form data:', formData);
  };
 
  return (
    <RecurlyPaymentForm
      elements={elements}
      onSubmit={handleSubmit}
      isSubmitting={false}
    >
      <button type="submit">Pay Now</button>
    </RecurlyPaymentForm>
  );
}

Webhooks

Setting up Webhook Endpoint

Create an API route at app/api/webhooks/recurly/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { verifyRecurlySignature } from '@/lib/recurly/webhook/verify-signature';
import { RecurlyWebhookPayloadSchema } from '@/lib/recurly/webhook/types';
import { handleWebhookEvent } from '@/lib/recurly/webhook/handlers';
 
export async function POST(request: NextRequest) {
  try {
    const webhookSecret = process.env.NEXT_SSR_RECURLY_WEBHOOK_KEY;
    if (!webhookSecret) {
      return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 });
    }
 
    const rawBody = await request.text();
    const recurlySignatureHeader = request.headers.get('recurly-signature');
 
    // Verify signature
    const isValid = verifyRecurlySignature(recurlySignatureHeader, rawBody, webhookSecret);
    if (!isValid) {
      return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
    }
 
    // Parse and validate payload
    const payload = RecurlyWebhookPayloadSchema.parse(JSON.parse(rawBody));
 
    // Handle the event
    const result = await handleWebhookEvent(payload);
 
    return NextResponse.json({ received: true, handled: result.handled }, { status: 200 });
  } catch (error) {
    console.error('[Recurly Webhook] Error:', error);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Configuring Webhooks in Recurly

  1. Go to your Recurly Dashboard > Configuration > Webhooks
  2. Add a new webhook endpoint: https://your-domain.com/api/webhooks/recurly
  3. Select the events you want to receive
  4. Copy the signing key and add it to NEXT_SSR_RECURLY_WEBHOOK_KEY

Supported Events

The webhook handler supports the following event types:

Account Events:

  • new_account
  • billing_info_updated
  • reactivated_account
  • canceled_account

Subscription Events:

  • new_subscription
  • updated_subscription
  • renewed_subscription
  • canceled_subscription
  • expired_subscription

Invoice Events:

  • new_invoice
  • processing_invoice
  • closed_invoice
  • past_due_invoice

Payment Events:

  • successful_payment
  • failed_payment
  • void_payment
  • successful_refund

Customizing Webhook Handlers

You can customize the webhook handlers in lib/recurly/webhook/handlers/:

// lib/recurly/webhook/handlers/subscription.ts
 
async function handleNewSubscription(payload: SubscriptionWebhookPayload) {
  console.log('New subscription created:', {
    subscription_id: payload.subscription?.id,
    account_id: payload.subscription?.account_id,
    plan_code: payload.subscription?.plan_code,
  });
 
  // Add your custom logic here
  // For example: send welcome email, update database, etc.
 
  return {
    success: true,
    handled: true,
  };
}

Security

Signature Verification

All webhook payloads are verified using HMAC-SHA256 signatures. The signature verification:

  • Validates the timestamp (5-minute tolerance)
  • Prevents replay attacks
  • Uses timing-safe comparison

Environment Variables

  • Never expose NEXT_SSR_RECURLY_API_KEY or NEXT_SSR_RECURLY_WEBHOOK_KEY to the client
  • Only NEXT_PUBLIC_RECURLY_PUBLIC_KEY should be client-accessible
  • Store sensitive keys in environment variables, not in code

Tokenization

Payment card data never touches your server:

  1. Recurly.js tokenizes card details in the browser
  2. Only the token is sent to your server
  3. Your server uses the token to create the subscription

Error Handling

The integration includes user-friendly error messages for common payment failures:

{
  success: false,
  error: 'Your card has been declined. Please check your information or use another card.',
  code: 'CARD_DECLINED'
}

Supported error codes:

  • CARD_DECLINED
  • INSUFFICIENT_FUNDS
  • CARD_EXPIRED
  • INVALID_CARD
  • INVALID_CVV
  • PAYMENT_ERROR (generic)

TypeScript Support

All types are fully typed with TypeScript:

import type {
  CreateRecurlyAccountInput,
  CreateRecurlyAccountResponse,
  RecurlyPlan,
  RecurlyWebhookPayload,
} from '@/lib/recurly/types';

Resources

Troubleshooting

"Recurly.js not loaded" Error

Make sure the Recurly script is loaded before using the hooks:

const { isLoaded, isLoading } = useRecurlyScript();
 
if (!isLoaded) {
  return <div>Loading payment system...</div>;
}

Webhook Signature Verification Fails

  1. Verify NEXT_SSR_RECURLY_WEBHOOK_KEY matches your Recurly dashboard
  2. Ensure you're using the raw request body (not parsed JSON)
  3. Check your server time is synchronized (NTP)

Build Errors

If you see import errors during build, make sure registry is excluded in tsconfig.json:

{
  "exclude": ["node_modules", "registry"]
}