Skip to main content
Auto-charge subscriptions allow customers to authorize and tokenize a payment method once, with future invoices charged automatically via HitPay. This flow only works with tokenizable payment methods (Cards, ShopeePay, GrabPay).

How It Works

This integration connects Stripe’s Subscription API with HitPay’s Recurring Billing API, allowing customers to set up automatic payments using local payment methods while keeping all subscription records in Stripe.

Key Concepts

ComponentPurpose
Stripe SubscriptionManages the subscription lifecycle, billing periods, and invoice generation.
HitPay Recurring BillingTokenizes the customer’s payment method and processes automatic charges.
Stripe Payment RecordsRecords each HitPay charge back in Stripe for unified reporting and reconciliation.

API References

Payment Method Compatibility

MethodAuto-Charge SupportHitPay Recurring Method
ShopeePayYesshopee_recurring
GrabPayYesgrabpay_direct
CardsYescard

Payment Flow


Step 1: Configure Custom Payment Methods

2

Create Configuration File

Server-sideMap your Stripe CPM Type IDs to HitPay payment methods. Include the hitpayRecurringMethod for auto-charge support.
// config/payment-methods.ts

interface CustomPaymentMethodConfig {
  id: string;                     // Stripe CPM Type ID (cpmt_xxx)
  hitpayMethod: string;           // HitPay one-time payment method
  hitpayRecurringMethod?: string; // HitPay recurring billing method
  displayName: string;
  chargeAutomatically: boolean;   // Supports auto-charge subscriptions
}

export const CUSTOM_PAYMENT_METHODS: CustomPaymentMethodConfig[] = [
  {
    id: 'cpmt_YOUR_PAYNOW_ID',
    hitpayMethod: 'paynow_online',
    displayName: 'PayNow',
    chargeAutomatically: false,  // QR-based, no tokenization
  },
  {
    id: 'cpmt_YOUR_SHOPEEPAY_ID',
    hitpayMethod: 'shopee_pay',
    hitpayRecurringMethod: 'shopee_recurring',
    displayName: 'ShopeePay',
    chargeAutomatically: true,
  },
  {
    id: 'cpmt_YOUR_GRABPAY_ID',
    hitpayMethod: 'grabpay',
    hitpayRecurringMethod: 'grabpay_direct',
    displayName: 'GrabPay',
    chargeAutomatically: true,
  },
  {
    id: 'cpmt_YOUR_CARD_ID',
    hitpayMethod: 'card',
    hitpayRecurringMethod: 'card',
    displayName: 'Card',
    chargeAutomatically: true,
  },
];

export function supportsAutoCharge(cpmTypeId: string): boolean {
  const config = CUSTOM_PAYMENT_METHODS.find(pm => pm.id === cpmTypeId);
  return config?.chargeAutomatically ?? false;
}

export function getAutoChargeCpms(): CustomPaymentMethodConfig[] {
  return CUSTOM_PAYMENT_METHODS.filter(pm => pm.chargeAutomatically);
}
The chargeAutomatically flag indicates whether a payment method supports tokenization for recurring charges. QR-based methods like PayNow cannot be saved for future charges.
3

Create HitPay Recurring Billing Functions

Server-sideAdd helper functions to interact with HitPay’s Recurring Billing API.
// lib/hitpay.ts

const HITPAY_API_URL = process.env.NEXT_PUBLIC_HITPAY_ENV === 'production'
  ? 'https://api.hit-pay.com/v1'
  : 'https://api.sandbox.hit-pay.com/v1';

interface RecurringBillingRequest {
  customer_email: string;
  customer_name?: string;
  amount: string;
  currency: string;
  payment_methods: string[];
  redirect_url: string;
  webhook?: string;
  reference?: string;
}

interface RecurringBillingResponse {
  id: string;
  url: string;
  status: string;  // 'pending' | 'active' | 'canceled'
}

export async function createRecurringBilling(
  data: RecurringBillingRequest
): Promise<RecurringBillingResponse> {
  const formData = new URLSearchParams();
  Object.entries(data).forEach(([key, value]) => {
    if (Array.isArray(value)) {
      value.forEach(v => formData.append(`${key}[]`, v));
    } else if (value) {
      formData.append(key, value);
    }
  });

  const response = await fetch(`${HITPAY_API_URL}/recurring-billing`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'X-BUSINESS-API-KEY': process.env.HITPAY_API_KEY!,
    },
    body: formData.toString(),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to create recurring billing');
  }

  return response.json();
}

export async function chargeRecurringBilling(
  recurringBillingId: string,
  amount: number,
  currency: string
): Promise<{ payment_id: string; status: string }> {
  const response = await fetch(
    `${HITPAY_API_URL}/charge/recurring-billing/${recurringBillingId}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-BUSINESS-API-KEY': process.env.HITPAY_API_KEY!,
      },
      body: JSON.stringify({
        amount: amount.toFixed(2),
        currency,
      }),
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.message || 'Failed to charge recurring billing');
  }

  return response.json();
}

Step 2: Create Subscription

1

Configure Environment Variables

Server-sideSet up the required API keys and configuration for both Stripe and HitPay.
# Stripe (Standard Account Keys)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_SECRET_KEY=sk_test_xxx

# HitPay
HITPAY_API_KEY=xxx
HITPAY_SALT=xxx
NEXT_PUBLIC_HITPAY_ENV=sandbox  # or 'production'
NEXT_PUBLIC_BASE_URL=https://your-domain.com
Never expose your STRIPE_SECRET_KEY or HITPAY_API_KEY to the client. These should only be used server-side.
2

Create Subscription in Stripe

Server-sideCreate a Stripe subscription with charge_automatically collection method. This generates invoices that will be charged via HitPay.
// app/api/create-subscription/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';

export async function POST(request: Request) {
  const { priceId, email, cpmTypeId } = await request.json();

  // Create or retrieve customer
  let customer;
  const existingCustomers = await stripe.customers.list({ email, limit: 1 });
  if (existingCustomers.data.length > 0) {
    customer = existingCustomers.data[0];
  } else {
    customer = await stripe.customers.create({ email });
  }

  // Create subscription with automatic charging
  const subscription = await stripe.subscriptions.create({
    customer: customer.id,
    items: [{ price: priceId }],
    collection_method: 'charge_automatically',
    payment_behavior: 'default_incomplete',
    payment_settings: {
      payment_method_types: ['card'],
    },
  });

  // Get invoice amount for HitPay setup
  const invoice = await stripe.invoices.retrieve(
    subscription.latest_invoice as string
  );

  return NextResponse.json({
    subscriptionId: subscription.id,
    customerId: customer.id,
    invoiceId: invoice.id,
    invoiceAmount: invoice.amount_due / 100,
    currency: invoice.currency,
    billingType: 'charge_automatically',
  });
}
3

Create HitPay Recurring Billing Session

Server-sideCreate a HitPay recurring billing session to tokenize the customer’s payment method.
// app/api/hitpay/recurring-billing/create/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { createRecurringBilling } from '@/lib/hitpay';

export async function POST(request: Request) {
  const {
    customerId,
    subscriptionId,
    invoiceId,
    amount,
    currency,
    customerEmail,
    paymentMethod,  // e.g., 'card', 'shopee_recurring'
  } = await request.json();

  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';

  // Create HitPay recurring billing session
  const session = await createRecurringBilling({
    customer_email: customerEmail,
    amount: amount.toFixed(2),
    currency,
    payment_methods: [paymentMethod],
    redirect_url: `${baseUrl}/subscribe/setup?subscription_id=${subscriptionId}&customer_id=${customerId}&invoice_id=${invoiceId}`,
    reference: subscriptionId,
  });

  // Store recurring billing ID in customer metadata
  await stripe.customers.update(customerId, {
    metadata: {
      hitpay_recurring_billing_id: session.id,
      hitpay_payment_method: paymentMethod,
    },
  });

  return NextResponse.json({
    recurringBillingId: session.id,
    redirectUrl: session.url,
    status: session.status,
  });
}

Step 3: Handle Payment Authorization

1

Redirect Customer to HitPay

Client-sideAfter creating the recurring billing session, redirect the customer to HitPay to authorize their payment method.
// components/SubscriptionForm.tsx
const handleSubscribe = async () => {
  // 1. Create subscription in Stripe
  const subRes = await fetch('/api/create-subscription', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ priceId, email, cpmTypeId }),
  });
  const subData = await subRes.json();

  // 2. Create HitPay recurring billing session
  const rbRes = await fetch('/api/hitpay/recurring-billing/create', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      customerId: subData.customerId,
      subscriptionId: subData.subscriptionId,
      invoiceId: subData.invoiceId,
      amount: subData.invoiceAmount,
      currency: subData.currency,
      customerEmail: email,
      paymentMethod: selectedRecurringMethod, // e.g., 'shopee_recurring'
    }),
  });
  const rbData = await rbRes.json();

  // 3. Redirect to HitPay for authorization
  window.location.href = rbData.redirectUrl;
};
2

Process Setup Callback

Client-sideAfter the customer authorizes on HitPay, they’re redirected back to your setup page. Process the callback and charge the first invoice.
// app/subscribe/setup/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';

export default function SetupPage() {
  const searchParams = useSearchParams();
  const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing');
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const chargeFirstInvoice = async () => {
      const subscriptionId = searchParams.get('subscription_id');
      const invoiceId = searchParams.get('invoice_id');

      if (!invoiceId) {
        setError('Missing invoice ID');
        setStatus('error');
        return;
      }

      try {
        const response = await fetch('/api/subscription/charge-invoice', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ invoiceId }),
        });

        const data = await response.json();

        if (data.success) {
          setStatus('success');
          setTimeout(() => {
            window.location.href = `/subscribe/success?subscription_id=${subscriptionId}`;
          }, 2000);
        } else {
          setError(data.error || 'Failed to charge invoice');
          setStatus('error');
        }
      } catch (err) {
        setError('An error occurred');
        setStatus('error');
      }
    };

    chargeFirstInvoice();
  }, [searchParams]);

  return (
    <div className="setup-container">
      {status === 'processing' && (
        <div>
          <h2>Setting up your subscription...</h2>
          <p>Please wait while we process your first payment.</p>
        </div>
      )}
      {status === 'success' && (
        <div>
          <h2>Subscription Active!</h2>
          <p>Redirecting to your subscription details...</p>
        </div>
      )}
      {status === 'error' && (
        <div>
          <h2>Setup Failed</h2>
          <p>{error}</p>
          <a href="/subscriptions">Try Again</a>
        </div>
      )}
    </div>
  );
}
3

Charge First Invoice

Server-sideCharge the first invoice using the tokenized payment method and record the payment in Stripe.
// app/api/subscription/charge-invoice/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { chargeRecurringBilling } from '@/lib/hitpay';

export async function POST(request: Request) {
  const { invoiceId } = await request.json();

  // Get invoice and customer details
  const invoice = await stripe.invoices.retrieve(invoiceId);
  const customer = await stripe.customers.retrieve(invoice.customer as string);

  if (customer.deleted) {
    return NextResponse.json({ error: 'Customer not found' }, { status: 404 });
  }

  const recurringBillingId = customer.metadata.hitpay_recurring_billing_id;
  if (!recurringBillingId) {
    return NextResponse.json(
      { error: 'No recurring billing setup found' },
      { status: 400 }
    );
  }

  // Charge via HitPay
  const chargeResult = await chargeRecurringBilling(
    recurringBillingId,
    invoice.amount_due / 100,
    invoice.currency
  );

  if (chargeResult.status !== 'succeeded') {
    return NextResponse.json(
      { error: 'Charge failed', details: chargeResult },
      { status: 400 }
    );
  }

  // Create PaymentMethod and record payment
  const cpmTypeId = customer.metadata.hitpay_cpm_type_id;
  const paymentMethod = await stripe.paymentMethods.create({
    type: 'custom',
    custom: { type: cpmTypeId },
  });

  const timestamp = Math.floor(Date.now() / 1000);
  await (stripe as any).paymentRecords.reportPayment({
    amount_requested: {
      value: invoice.amount_due,
      currency: invoice.currency,
    },
    payment_method_details: {
      payment_method: paymentMethod.id,
    },
    processor_details: {
      type: 'custom',
      custom: {
        payment_reference: chargeResult.payment_id,
      },
    },
    initiated_at: timestamp,
    customer_presence: 'off_session',
    outcome: 'guaranteed',
    guaranteed: { guaranteed_at: timestamp },
    metadata: {
      invoice_id: invoiceId,
      hitpay_payment_id: chargeResult.payment_id,
    },
  });

  // Mark invoice as paid
  await stripe.invoices.pay(invoiceId, {
    paid_out_of_band: true,
  });

  return NextResponse.json({
    success: true,
    hitpayPaymentId: chargeResult.payment_id,
  });
}

Step 4: Handle Future Invoices

1

Setup Stripe Webhook

Server-sideHandle Stripe webhook events to automatically charge future invoices when they’re created.
// app/api/stripe/webhook/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { chargeRecurringBilling } from '@/lib/hitpay';

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  if (event.type === 'invoice.payment_failed') {
    const invoice = event.data.object;

    // Get customer's recurring billing ID
    const customer = await stripe.customers.retrieve(invoice.customer as string);
    if (customer.deleted) return NextResponse.json({ received: true });

    const recurringBillingId = customer.metadata.hitpay_recurring_billing_id;
    if (!recurringBillingId) return NextResponse.json({ received: true });

    // Attempt charge via HitPay
    try {
      const chargeResult = await chargeRecurringBilling(
        recurringBillingId,
        invoice.amount_due / 100,
        invoice.currency
      );

      if (chargeResult.status === 'succeeded') {
        // Record payment and mark invoice as paid
        // (Same logic as charge-invoice endpoint)
      }
    } catch (error) {
      console.error('Auto-charge failed:', error);
    }
  }

  return NextResponse.json({ received: true });
}

Stripe Webhooks Guide

Learn how to configure webhooks in your Stripe Dashboard.

Testing

  1. Create a subscription with “Auto-Charge” option
  2. Select a tokenizable CPM (ShopeePay, GrabPay, or Card)
  3. Complete authorization on HitPay redirect
  4. Verify:
    • Customer metadata has hitpay_recurring_billing_id
    • First invoice is paid
    • Subscription is active
In sandbox mode, you may need to manually trigger subsequent invoice payments. In production, the Stripe webhook handles this automatically.

Troubleshooting

Only payment methods with chargeAutomatically: true support auto-charge. PayNow (QR-based) cannot be tokenized and must use the out-of-band flow.
  • Verify the recurring billing session is active (not pending or canceled)
  • Check that the customer authorized the payment method
  • Ensure the amount and currency are correct
  • Verify webhook endpoint is configured in Stripe Dashboard
  • Check webhook signature verification
  • Ensure STRIPE_WEBHOOK_SECRET is set correctly