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
Component Purpose Stripe Subscription Manages the subscription lifecycle, billing periods, and invoice generation. HitPay Recurring Billing Tokenizes the customer’s payment method and processes automatic charges. Stripe Payment Records Records each HitPay charge back in Stripe for unified reporting and reconciliation.
API References
Payment Method Compatibility
Method Auto-Charge Support HitPay Recurring Method ShopeePay Yes shopee_recurringGrabPay Yes grabpay_directCards Yes card
Payment Flow
Create Custom Payment Methods on Stripe Dashboard
Create Configuration File
Server-side Map 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.
Create HitPay Recurring Billing Functions
Server-side Add 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
Configure Environment Variables
Server-side Set 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.
Create Subscription in Stripe
Server-side Create 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' ,
});
}
Create HitPay Recurring Billing Session
Server-side Create 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
Redirect Customer to HitPay
Client-side After 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 ;
};
Process Setup Callback
Client-side After 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 >
);
}
Charge First Invoice
Server-side Charge 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
Setup Stripe Webhook
Server-side Handle 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
Create a subscription with “Auto-Charge” option
Select a tokenizable CPM (ShopeePay, GrabPay, or Card)
Complete authorization on HitPay redirect
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
Auto-charge not available for a CPM
Only payment methods with chargeAutomatically: true support auto-charge. PayNow (QR-based) cannot be tokenized and must use the out-of-band flow.
Recurring billing charge fails
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
Webhook not triggering charges
Verify webhook endpoint is configured in Stripe Dashboard
Check webhook signature verification
Ensure STRIPE_WEBHOOK_SECRET is set correctly