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

HitPay Recurring Billing API

Create recurring billing sessions and charge saved payment methods.

Stripe Subscriptions API

Manage subscription lifecycle and invoice generation.

Third-Party Payment Processing

Stripe’s recommended pattern for processing subscription payments via external payment processors.

Payment Method Compatibility

MethodAuto-Charge SupportHitPay Recurring Method
ShopeePayYesshopee_recurring
GrabPayYesgrabpay_direct
CardsYescard

Payment Flow

API Calls Summary

StepAPIEndpoint / MethodPurpose
1Stripecustomers.create()Create or retrieve customer
2Stripesubscriptions.create()Create subscription with charge_automatically
3HitPayPOST /recurring-billingCreate recurring billing session (generate_embed: true returns QR or direct link)
4aHitPay(redirect)Redirect flow: Customer authorizes on HitPay’s hosted page
4bHitPayGET /recurring-billing/{id}Embedded flow: Poll until status === 'active' (customer scanned QR or opened app)
5HitPayPOST /charge/recurring-billing/{id}Charge first invoice via saved payment method
6StripepaymentMethods.create()Create CPM PaymentMethod for reporting
7StripepaymentRecords.reportPayment()Record payment for dashboard visibility
8Stripeinvoices.pay()Mark invoice as paid
Stripe Webhookinvoice.payment_attempt_requiredTriggered by Stripe for each renewal invoice
9HitPayPOST /charge/recurring-billing/{id}Auto-charge renewal invoices via webhook

Step 1: Configure Custom Payment Methods

1

Create Custom Payment Methods on Stripe Dashboard

Stripe DashboardCreate Custom Payment Method types in your Stripe Dashboard for each HitPay payment method you want to offer.

Create CPM in Stripe Dashboard

Follow Stripe’s guide to create Custom Payment Method types and get your CPM Type IDs.

Download Payment Icons

Download official HitPay payment method icons (PayNow, ShopeePay, GrabPay, FPX, and more) optimized for Stripe 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.
You’ll have different CPM Type IDs in sandbox and production — they are separate Stripe accounts. Consider using environment variables or an ids: { sandbox: string, production: string } structure so you can switch between environments without code changes.
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_SITE_URL=https://your-domain.com

# Stripe Webhook (for auto-charge renewals — see Step 4)
STRIPE_WEBHOOK_SECRET=whsec_xxx
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_SITE_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

Alternative: Embedded Authorization (generate_embed)

Client-side + Server-sideInstead of redirecting to HitPay’s hosted checkout, you can embed the authorization flow directly in your page using the generate_embed: true parameter. The recurring billing creation endpoint already includes this parameter — the response returns an embeddable QR code or a direct app link depending on the payment method.What each method returns with generate_embed: true:
HitPay MethodResponse FieldCustomer Experience
shopee_recurringdirect_link.direct_link_urlButton that opens the Shopee app
grabpay_directdirect_link.direct_link_urlButton that opens the Grab app
cardqr_code_data.qr_codeInline QR code for the customer to scan
Frontend: handle QR code vs. direct linkAfter calling /api/hitpay/recurring-billing/create, check which fields are present in the response and render accordingly:
// components/EmbeddedAuthorizationForm.tsx
'use client';

import { useState, useEffect } from 'react';
import { QRCodeSVG } from 'qrcode.react';

interface Props {
  subscriptionId: string;
  invoiceId: string;
  customerId: string;
  amount: number;
  currency: string;
  customerEmail: string;
  selectedRecurringMethod: string; // e.g., 'shopee_recurring', 'card'
}

export function EmbeddedAuthorizationForm({
  subscriptionId, invoiceId, customerId, amount, currency,
  customerEmail, selectedRecurringMethod,
}: Props) {
  const [qrCode, setQrCode] = useState<string | null>(null);
  const [directLink, setDirectLink] = useState<string | null>(null);
  const [recurringBillingId, setRecurringBillingId] = useState<string | null>(null);
  const [status, setStatus] = useState<'idle' | 'authorizing' | 'charging' | 'success' | 'error'>('idle');

  const startAuthorization = async () => {
    setStatus('authorizing');

    // Create HitPay recurring billing session (generate_embed: true is set server-side)
    const res = await fetch('/api/hitpay/recurring-billing/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        customerId,
        subscriptionId,
        invoiceId,
        amount,
        currency,
        customerEmail,
        paymentMethod: selectedRecurringMethod,
      }),
    });
    const data = await res.json();

    setRecurringBillingId(data.recurringBillingId);

    if (data.qrCode) {
      // Card-based methods: show inline QR code
      setQrCode(data.qrCode);
    } else if (data.directLinkUrl) {
      // App-based methods (ShopeePay, GrabPay): show open-app button
      setDirectLink(data.directLinkUrl);
    }
  };

  // Poll for authorization status
  useEffect(() => {
    if (!recurringBillingId || status !== 'authorizing') return;

    const poll = setInterval(async () => {
      const res = await fetch(
        `/api/hitpay/recurring-billing/status?id=${recurringBillingId}`
      );
      const { status: sessionStatus } = await res.json();

      if (sessionStatus === 'active') {
        clearInterval(poll);
        setStatus('charging');

        // Charge first invoice using the now-active recurring billing
        await fetch('/api/subscription/charge-invoice', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ invoiceId }),
        });

        setStatus('success');
        window.location.href = `/subscribe/success?subscription_id=${subscriptionId}`;
      }
    }, 3000);

    return () => clearInterval(poll);
  }, [recurringBillingId, status, invoiceId, subscriptionId]);

  if (status === 'idle') {
    return (
      <button onClick={startAuthorization}>
        Set up Auto-Charge
      </button>
    );
  }

  if (qrCode) {
    return (
      <div>
        <h3>Scan to Authorize</h3>
        <QRCodeSVG value={qrCode} size={256} />
        <p>Scan this QR code with your payment app to authorize recurring charges.</p>
      </div>
    );
  }

  if (directLink) {
    return (
      <div>
        <a href={directLink} target="_blank" rel="noopener noreferrer">
          Open App to Authorize
        </a>
        <p>Waiting for authorization...</p>
      </div>
    );
  }

  return <p>Setting up authorization...</p>;
}
Backend: status polling endpointAdd a lightweight endpoint that the frontend polls to check the recurring billing session status:
// app/api/hitpay/recurring-billing/status/route.ts
import { NextResponse } from 'next/server';
import { getRecurringBilling } from '@/lib/hitpay';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const id = searchParams.get('id');

  if (!id) {
    return NextResponse.json({ error: 'Missing id' }, { status: 400 });
  }

  const session = await getRecurringBilling(id);
  return NextResponse.json({ status: session.status });
}
The session status progresses through:
  • pending — customer has not yet authorized
  • active — customer has authorized the payment method (safe to charge)
  • canceled — authorization was canceled or expired
After the session reaches active, call /api/subscription/charge-invoice to charge the first invoice — the same endpoint used in the redirect flow. Future renewals are handled automatically by the Stripe webhook.
3

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>
  );
}
4

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 the invoice.payment_attempt_required Stripe webhook event to automatically charge future invoices.Webhook setup:
  1. Go to Stripe DashboardDevelopersWebhooksAdd endpoint
  2. Enter your endpoint URL: https://your-domain.com/api/stripe/webhook
  3. Select the event: invoice.payment_attempt_required
  4. Copy the Signing secret (whsec_xxx) and add it as STRIPE_WEBHOOK_SECRET
For local development, use the Stripe CLI:
stripe listen --forward-to localhost:3000/api/stripe/webhook
// 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_attempt_required') {
    const invoice = event.data.object;

    // Skip first invoice — it's charged manually after HitPay authorization
    // to avoid double-charging the customer on subscription creation
    if (invoice.billing_reason === 'subscription_create') {
      return NextResponse.json({ received: true });
    }

    // Skip if nothing is owed
    if (!invoice.amount_due || invoice.amount_due === 0) {
      return NextResponse.json({ received: true });
    }

    // 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