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 Embedded Recurring Billing

Create recurring billing sessions with embedded authorization — returns QR codes and direct app links instead of redirecting to a hosted checkout page.

HitPay Save Payment Method & Charge

How to charge a saved payment method via the HitPay API — the same charge endpoint used to bill both the first invoice and all future renewals.

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

Stripe Objects

This integration touches six Stripe objects. Understanding each one makes it easier to follow the code and debug issues.
A Stripe Customer represents the subscriber. In this integration, the customer object is the bridge between Stripe and HitPay — it stores the HitPay recurring billing session ID in its metadata so every future invoice knows which HitPay token to charge.Key fields used:
FieldPurpose
metadata.hitpay_recurring_billing_idHitPay recurring billing session ID — used to charge all invoices
metadata.hitpay_payment_methodThe HitPay recurring method (e.g. shopee_recurring, grabpay_direct, card)
metadata.hitpay_cpm_type_idStripe CPM Type ID — used when creating a PaymentMethod for reporting
The customer is created (or retrieved by email) when the subscription is set up, and its metadata is populated after the HitPay recurring billing session is created.
A Stripe Subscription manages the billing lifecycle — it defines the billing interval, generates invoices on each renewal, and tracks subscription status (active, past_due, canceled, etc.).In this integration, subscriptions are created with collection_method: 'charge_automatically' and payment_behavior: 'default_incomplete'. This tells Stripe to generate invoices for each billing cycle and fire the invoice.payment_attempt_required webhook — but not to attempt payment via Stripe’s own payment methods. Your webhook handler performs the actual charge via HitPay.Key fields used:
FieldValue / Purpose
collection_methodcharge_automatically — generates invoices and fires the payment webhook
payment_behaviordefault_incomplete — subscription stays incomplete until the first invoice is paid
latest_invoiceID of the first invoice, retrieved immediately after subscription creation
statusTracks whether the subscription is active, incomplete, or past due
A Stripe Invoice represents one billing cycle. Stripe creates an invoice automatically for each period of a charge_automatically subscription.There are two types of invoices in this flow:
  • First invoice (billing_reason: 'subscription_create'): Created when the subscription is set up. Charged manually after the customer authorizes their payment method on HitPay. Your webhook handler skips this one to avoid double-charging.
  • Renewal invoices (billing_reason: 'subscription_cycle'): Generated automatically at each billing period. The invoice.payment_attempt_required webhook fires for these, and your handler charges them via HitPay.
After a successful HitPay charge, the invoice is marked paid using invoices.pay(invoiceId, { paid_out_of_band: true }) — this closes the invoice without Stripe attempting its own payment.Key fields used:
FieldPurpose
amount_dueAmount to charge (in the smallest currency unit, e.g. cents)
currencyCurrency for the HitPay charge
customerCustomer ID — used to retrieve hitpay_recurring_billing_id from metadata
billing_reasonsubscription_create vs subscription_cycle — used to skip the first invoice in the webhook
idStored in PaymentRecord.metadata for reconciliation
A Stripe PaymentMethod of type custom represents a Custom Payment Method — it is created on the fly for each HitPay charge, purely for reporting purposes.This object is required as input to paymentRecords.reportPayment(). It carries the CPM Type ID that tells Stripe which custom payment method was used (e.g. your ShopeePay or GrabPay CPM).
const paymentMethod = await stripe.paymentMethods.create({
  type: 'custom',
  custom: { type: cpmTypeId },  // e.g. 'cpmt_xxx'
});
Note: This PaymentMethod is not saved to the customer or reused. A new one is created per charge solely to satisfy the reportPayment() API.
A Stripe PaymentRecord is how external charges are made visible in the Stripe Dashboard. After charging via HitPay, you call paymentRecords.reportPayment() to record the charge in Stripe — this gives your team unified reporting and reconciliation without HitPay payments being invisible inside Stripe.The PaymentRecord stores the HitPay payment ID in two places (for easy retrieval when processing refunds):
  • processor_details.custom.payment_reference — the HitPay payment ID
  • metadata.hitpay_payment_id — same value, accessible as plain metadata
Key fields used:
FieldValue / Purpose
amount_requested.valueInvoice amount_due (in smallest currency unit)
payment_method_details.payment_methodThe custom PaymentMethod ID created above
processor_details.custom.payment_referenceHitPay payment_id returned by the charge API
customer_presenceoff_session — charge was made without the customer present
outcomeguaranteed — HitPay confirmed the charge succeeded
metadata.invoice_idStripe invoice ID — links the record back to the billing cycle
The invoice.payment_attempt_required event is fired by Stripe whenever a subscription invoice needs to be charged under collection_method: charge_automatically.This is the trigger for all renewal auto-charges. Your webhook handler receives this event, looks up the customer’s hitpay_recurring_billing_id, and calls HitPay to charge the invoice.Important: Stripe fires this event for the first invoice too (billing_reason: 'subscription_create'). Your handler must skip it — the first invoice is already charged manually after the customer completes HitPay authorization. Charging it again here would double-bill the customer.
if (invoice.billing_reason === 'subscription_create') {
  return NextResponse.json({ received: true }); // skip — already charged manually
}

Step 1: Configure Custom Payment Methods

Register each HitPay payment method as a Custom Payment Method (CPM) type in your Stripe Dashboard and map those CPM Type IDs to HitPay method identifiers in a config file.
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.

Step 2: Create Subscription

When the customer submits the checkout form, two server calls happen in sequence:
  1. Create a Stripe Subscription — creates an incomplete subscription and a pending invoice
  2. Create a HitPay Recurring Billing session — tokenizes the payment method and returns authorization data (QR code or direct app link)
The authorization data from step 2 is then used in Step 3 to let the customer approve the payment method inline.
1

Configure Environment Variables

Server-sideSet up the required API keys and configuration for both Stripe and HitPay.
# Stripe
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 STRIPE_SECRET_KEY or HITPAY_API_KEY to the client. These are server-side only.
2

Client: Collect customer info and initiate setup

Client-sideCollect the customer’s email address and selected plan, then make two sequential server calls: first to create the Stripe subscription, then to start the HitPay authorization session.
// 1. Create Stripe subscription — returns subscription + invoice details
const subRes = await fetch('/api/create-subscription', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    email,
    priceId,           // Stripe Price ID for the selected plan
    cpmTypeId,         // Stripe CPM Type ID for the selected payment method
  }),
});
const { subscriptionId, customerId, invoiceId, invoiceAmount, currency } = await subRes.json();

// 2. Create HitPay recurring billing session — returns authorization embed data
const hitpayRes = await fetch('/api/hitpay/recurring-billing/create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    customerId,
    subscriptionId,
    invoiceId,
    amount: invoiceAmount,
    currency,
    customerEmail: email,
    paymentMethod: selectedRecurringMethod, // e.g. 'card', 'shopee_recurring', 'grabpay_direct'
  }),
});
const { directLinkUrl, directLinkAppUrl, qrCode } = await hitpayRes.json();

// → Pass this data to Step 3 to render inline authorization
selectedRecurringMethod comes from the CPM config (hitpayRecurringMethod) for whichever payment method the customer chose. See the CUSTOM_PAYMENT_METHODS config in Step 1.
3

Server: Create Stripe Subscription

Server-sideCreate a Stripe subscription with charge_automatically collection method. The subscription starts as incomplete — it will become active once the first invoice is charged in Step 3.What this returns: subscriptionId, customerId, invoiceId, and the invoiceAmount needed to create the HitPay session.
// 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',
  });
}
4

Server: Create HitPay Recurring Billing Session

Server-sideCall POST /v1/recurring-billing with generate_embed=true. This tokenizes the customer’s payment method and returns the authorization data needed for inline rendering. Store the recurring_billing_id on the Stripe customer — you’ll need it for all future charges.What this returns: A session.id (the recurring billing token), plus either qr_code_data (for card) or direct_link (for ShopeePay / GrabPay).
// app/api/hitpay/recurring-billing/create/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';

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

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';

  // POST /v1/recurring-billing — tokenize the customer's payment method.
  // generate_embed=true returns a QR code or direct app link for inline authorization
  // instead of redirecting the customer to HitPay's hosted checkout page.
  const params = new URLSearchParams({
    customer_email: customerEmail,
    amount: amount.toFixed(2),
    currency,
    redirect_url: `${baseUrl}/subscribe/setup?subscription_id=${subscriptionId}&customer_id=${customerId}&invoice_id=${invoiceId}`,
    reference: subscriptionId,
    generate_embed: 'true',
  });
  params.append('payment_methods[]', paymentMethod);

  const hitpayRes = 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: params.toString(),
  });
  const session = await hitpayRes.json();

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

  // Extract embed fields for the frontend
  const directLinkUrl = session.direct_link?.direct_link_url ?? null;
  const directLinkAppUrl = session.direct_link?.direct_link_app_url ?? null;
  const qrCode = session.qr_code_data?.qr_code ?? null;

  return NextResponse.json({
    recurringBillingId: session.id,
    // Embed data — use one of these to authorize inline (preferred)
    directLinkUrl,       // ShopeePay / GrabPay: open in browser
    directLinkAppUrl,    // ShopeePay only: open Shopee app directly (mobile)
    qrCode,              // Card: render as QR code
    // Fallback: redirect to HitPay's hosted checkout (not ideal — sends customer away)
    hostedCheckoutUrl: session.url,
    status: session.status,
  });
}

Step 3: Handle Payment Authorization

After creating the HitPay recurring billing session in Step 2, present the authorization UI to the customer.
1

Charge via HitPay

Server-sideRetrieve the customer’s hitpay_recurring_billing_id from their Stripe metadata and call POST /v1/charge/recurring-billing/{id} to debit the saved payment method.
const HITPAY_API_URL = process.env.NEXT_PUBLIC_HITPAY_ENV === 'production'
  ? 'https://api.hit-pay.com/v1'
  : 'https://api.sandbox.hit-pay.com/v1';

const invoice = await stripe.invoices.retrieve(invoiceId);
const customer = await stripe.customers.retrieve(invoice.customer as string);
const recurringBillingId = customer.metadata.hitpay_recurring_billing_id;

const hitpayRes = 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: (invoice.amount_due / 100).toFixed(2),
      currency: invoice.currency,
    }),
  }
);
const charge = await hitpayRes.json();
// charge.status: 'succeeded' | 'pending' | 'failed'
// charge.payment_id: HitPay payment reference
Treat both succeeded and pending as success. pending means the charge is processing asynchronously — store charge.payment_id in invoice metadata and let the webhook confirm it later.
2

Report Payment in Stripe

Server-sideRecord the HitPay charge in Stripe using stripe.paymentRecords.reportPayment(). This gives Stripe a record of the payment for reconciliation and unified reporting. Without this step, Stripe has no visibility into what was collected by HitPay.
const paymentMethod = await stripe.paymentMethods.create({
  type: 'custom',
  custom: { type: customer.metadata.hitpay_cpm_type_id },
});

const now = Math.floor(Date.now() / 1000);
const paymentRecord = await stripe.paymentRecords.reportPayment({
  amount_requested: { value: invoice.amount_due, currency: invoice.currency },
  customer_details: { customer: invoice.customer },
  payment_method_details: { payment_method: paymentMethod.id },
  processor_details: {
    type: 'custom',
    custom: { payment_reference: charge.payment_id },
  },
  initiated_at: now,
  customer_presence: 'off_session',
  outcome: 'guaranteed',
  guaranteed: { guaranteed_at: now },
});
Use outcome: 'guaranteed' for succeeded charges. If the charge returned pending, use outcome: 'failed' temporarily — update the PaymentRecord to guaranteed once the HitPay webhook confirms the charge.
3

Mark Invoice as Paid

Server-sideMark the Stripe invoice as paid with paid_out_of_band: true. This tells Stripe the payment was collected externally (via HitPay), transitions the invoice to paid, and activates the subscription.
await stripe.invoices.pay(invoiceId, {
  paid_out_of_band: true,
});
Store the HitPay payment ID and Stripe payment record ID in invoice metadata as an idempotency guard — the webhook handler checks for these before charging future invoices to avoid double-charging.
await stripe.invoices.update(invoiceId, {
  metadata: {
    hitpay_payment_id: charge.payment_id,
    stripe_payment_record_id: paymentRecord.id,
  },
});
Once the invoice is paid, Stripe transitions the subscription from incompleteactive. Future renewals are handled automatically by the webhook in Step 4.
// app/api/subscription/charge-invoice/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';

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

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

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

  // Step 1: Charge via HitPay
  const hitpayRes = 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: (invoice.amount_due / 100).toFixed(2),
        currency: invoice.currency,
      }),
    }
  );
  const charge = await hitpayRes.json();

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

  // Step 2: Report payment in Stripe
  const paymentMethod = await stripe.paymentMethods.create({
    type: 'custom',
    custom: { type: customer.metadata.hitpay_cpm_type_id },
  });

  const now = Math.floor(Date.now() / 1000);
  const paymentRecord = await stripe.paymentRecords.reportPayment({
    amount_requested: { value: invoice.amount_due, currency: invoice.currency },
    customer_details: { customer: invoice.customer },
    payment_method_details: { payment_method: paymentMethod.id },
    processor_details: {
      type: 'custom',
      custom: { payment_reference: charge.payment_id },
    },
    initiated_at: now,
    customer_presence: 'off_session',
    outcome: 'guaranteed',
    guaranteed: { guaranteed_at: now },
  });

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

  // Store IDs for idempotency (webhook skips invoices already charged)
  await stripe.invoices.update(invoiceId, {
    metadata: {
      hitpay_payment_id: charge.payment_id,
      stripe_payment_record_id: paymentRecord.id,
    },
  });

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

Step 4: Handle Future Invoices

On each billing cycle, Stripe fires invoice.payment_attempt_required when a new invoice is created. A webhook handler automatically charges the customer’s saved HitPay payment method and records the payment in Stripe — no customer action required.
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';

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

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

    // POST /v1/charge/recurring-billing/{id} — charge the saved payment method
    // See: /apis/guide/save-payment-method#step-3-charge-the-saved-payment-method
    try {
      const hitpayRes = 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: (invoice.amount_due / 100).toFixed(2),
            currency: invoice.currency,
          }),
        }
      );
      const chargeResult = await hitpayRes.json();

      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.

FAQ

Auto-charge requires tokenizable payment methods: Cards, ShopeePay, and GrabPay. PayNow is QR-based and cannot be tokenized — customers using PayNow must pay each invoice manually via 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