Skip to main content
Out-of-band subscriptions allow customers to pay each invoice manually using any HitPay payment method. This is the same flow as one-time payments, repeated per billing cycle. This approach works with all payment methods including QR-based ones like PayNow that cannot be tokenized.

How It Works

This integration connects Stripe’s Subscription API with HitPay’s Payment Request API, allowing customers to pay each invoice using local payment methods while keeping all subscription records in Stripe.

Key Concepts

ComponentPurpose
Stripe SubscriptionManages subscription lifecycle with send_invoice collection method. Generates invoices each billing cycle.
HitPay Payment RequestCreates one-time payment requests (QR codes or redirect) for each invoice.
Stripe Payment RecordsRecords each HitPay payment in Stripe for unified reporting and reconciliation.

API References

Out-of-band subscriptions work with all payment methods since each invoice is paid as a one-time payment. This is ideal for QR-based methods like PayNow that cannot be tokenized.

Payment Flow


Step 1: Configure Custom Payment Methods

2

Create Configuration File

Server-sideMap your Stripe CPM Type IDs to HitPay payment methods. For out-of-band subscriptions, you only need the hitpayMethod (one-time payment method).
// 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 (optional)
  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 getHitpayMethod(cpmTypeId: string): string | undefined {
  const config = CUSTOM_PAYMENT_METHODS.find(pm => pm.id === cpmTypeId);
  return config?.hitpayMethod;
}

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'
Never expose your STRIPE_SECRET_KEY or HITPAY_API_KEY to the client. These should only be used server-side.
2

Create Subscription with Send Invoice

Server-sideCreate a Stripe subscription with send_invoice collection method. This creates invoices that customers pay manually each billing cycle.
// 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 } = 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 manual invoice payment
  const subscription = await stripe.subscriptions.create({
    customer: customer.id,
    items: [{ price: priceId }],
    collection_method: 'send_invoice',
    days_until_due: 0,  // Invoice due immediately
    payment_settings: {
      payment_method_types: ['card'],
    },
  });

  // Finalize invoice to get PaymentIntent
  const invoice = await stripe.invoices.retrieve(subscription.latest_invoice as string);
  const finalizedInvoice = await stripe.invoices.finalizeInvoice(invoice.id);

  // Get client secret for Payment Element
  let clientSecret: string;
  if (finalizedInvoice.payment_intent) {
    const pi = await stripe.paymentIntents.retrieve(
      finalizedInvoice.payment_intent as string
    );
    clientSecret = pi.client_secret!;
  } else {
    // Create a UI PaymentIntent if needed
    const pi = await stripe.paymentIntents.create({
      amount: finalizedInvoice.amount_due,
      currency: finalizedInvoice.currency,
      customer: customer.id,
      metadata: { invoice_id: finalizedInvoice.id },
    });
    clientSecret = pi.client_secret!;
  }

  return NextResponse.json({
    subscriptionId: subscription.id,
    invoiceId: finalizedInvoice.id,
    clientSecret,
    customerId: customer.id,
    billingType: 'out_of_band',
    type: 'payment',
  });
}
The key difference from auto-charge is collection_method: 'send_invoice' with days_until_due: 0, which creates an invoice due immediately that customers pay manually.

Step 3: Handle Invoice Payment

1

Create HitPay Payment Request

Server-sideUse the same HitPay Payment Request endpoint from the one-time payments flow. When a customer selects a custom payment method, create a payment request for the invoice amount.

HitPay Payment Request

See the one-time payments guide for the HitPay payment request implementation.
2

Display QR Code and Poll for Payment

Client-sideShow the QR code or redirect URL and poll for payment completion. This is the same flow as one-time payments.
// components/SubscriptionCheckoutForm.tsx
'use client';

import { useState, useCallback } from 'react';
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { QRCodeSVG } from 'qrcode.react';
import { CUSTOM_PAYMENT_METHODS, getHitpayMethod } from '@/config/payment-methods';

interface Props {
  amount: number;
  currency: string;
  invoiceId: string;
  subscriptionId: string;
}

export function SubscriptionCheckoutForm({
  amount,
  currency,
  invoiceId,
  subscriptionId,
}: Props) {
  const stripe = useStripe();
  const elements = useElements();

  const [selectedCpm, setSelectedCpm] = useState<string | null>(null);
  const [qrCode, setQrCode] = useState<string | null>(null);
  const [paymentRequestId, setPaymentRequestId] = useState<string | null>(null);
  const [isPolling, setIsPolling] = useState(false);

  const isCustomPaymentMethod = (type: string) => {
    return CUSTOM_PAYMENT_METHODS.some(pm => pm.id === type);
  };

  const handlePaymentElementChange = async (event: any) => {
    const paymentType = event.value?.type;

    if (paymentType && isCustomPaymentMethod(paymentType)) {
      setSelectedCpm(paymentType);
      await createHitPayRequest(paymentType);
    } else {
      setSelectedCpm(null);
      setQrCode(null);
    }
  };

  const createHitPayRequest = async (cpmTypeId: string) => {
    const hitpayMethod = getHitpayMethod(cpmTypeId);
    if (!hitpayMethod) return;

    const response = await fetch('/api/hitpay/create', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        amount,
        currency,
        paymentMethod: hitpayMethod,
        paymentIntentId: invoiceId,  // Use invoice ID as reference
      }),
    });

    const data = await response.json();

    if (data.qrCode) {
      setQrCode(data.qrCode);
      setPaymentRequestId(data.paymentRequestId);
      startPolling(data.paymentRequestId, cpmTypeId);
    }
  };

  const startPolling = useCallback((requestId: string, cpmTypeId: string) => {
    setIsPolling(true);
    let attempts = 0;

    const poll = async () => {
      if (attempts >= 60) {
        setIsPolling(false);
        return;
      }

      // Check HitPay status
      const statusRes = await fetch('/api/hitpay/status', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ paymentRequestId: requestId }),
      });
      const { status } = await statusRes.json();

      if (status === 'completed') {
        // Mark invoice as paid
        await fetch('/api/subscription/pay-invoice', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            invoiceId,
            paymentRequestId: requestId,
            cpmTypeId,
          }),
        });

        setIsPolling(false);
        window.location.href = '/subscribe/success';
      } else {
        attempts++;
        setTimeout(poll, 3000);
      }
    };

    poll();
  }, [invoiceId]);

  return (
    <div>
      <PaymentElement onChange={handlePaymentElementChange} />

      {qrCode && (
        <div className="qr-container">
          <h3>Scan to Pay Invoice</h3>
          <QRCodeSVG value={qrCode} size={256} />
          {isPolling && <p>Waiting for payment...</p>}
        </div>
      )}

      {!selectedCpm && (
        <button
          onClick={() => stripe?.confirmPayment({
            elements: elements!,
            confirmParams: { return_url: `${window.location.origin}/subscribe/success` },
          })}
          disabled={!stripe}
        >
          Subscribe
        </button>
      )}
    </div>
  );
}
3

Mark Invoice as Paid

Server-sideAfter verifying the HitPay payment, create a Payment Record in Stripe and mark the invoice as paid out-of-band.
// app/api/subscription/pay-invoice/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { getHitPayPaymentStatus } from '@/lib/hitpay';

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

  // Verify HitPay payment completed
  const { status, payment_id } = await getHitPayPaymentStatus(paymentRequestId);
  if (status !== 'completed') {
    return NextResponse.json(
      { error: 'Payment not completed' },
      { status: 400 }
    );
  }

  // Create PaymentMethod with CPM type
  const paymentMethod = await stripe.paymentMethods.create({
    type: 'custom',
    custom: { type: cpmTypeId },
  });

  // Get invoice details
  const invoice = await stripe.invoices.retrieve(invoiceId);

  // Record the payment
  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: payment_id || paymentRequestId,
      },
    },
    initiated_at: timestamp,
    customer_presence: 'on_session',
    outcome: 'guaranteed',
    guaranteed: { guaranteed_at: timestamp },
    metadata: {
      invoice_id: invoiceId,
      hitpay_payment_request_id: paymentRequestId,
    },
  });

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

  return NextResponse.json({
    success: true,
    invoiceId,
  });
}

Step 4: Handle Future Invoices

1

Future Invoice Flow

InfoEach billing cycle, Stripe automatically generates a new invoice. Since out-of-band subscriptions don’t use tokenized payment methods, customers pay each invoice manually using the same one-time payment flow.How it works:
  1. Stripe generates a new invoice at the start of each billing cycle
  2. You notify the customer (via email or your app) that a new invoice is ready
  3. Customer returns to your payment page and pays the invoice using any HitPay payment method
  4. The payment is recorded and the invoice is marked as paid
Unlike auto-charge subscriptions, there’s no webhook to automatically charge the customer. You can optionally set up a webhook for invoice.created to send payment reminders.

Testing

  1. Create a subscription with “Pay Each Invoice” option
  2. Select a CPM and scan the QR code
  3. Complete the mock payment in HitPay sandbox
  4. Verify:
    • Invoice marked as paid_out_of_band in Stripe
    • Payment Record created (prec_*)
    • Subscription is active

Troubleshooting

  • Check that paid_out_of_band: true is passed to invoices.pay()
  • Verify the Payment Record was created successfully
  • Check server logs for API errors
  • Verify the HitPay API key is correct
  • Check that the payment method supports QR codes
  • Ensure the amount and currency are valid
  • Check that polling is running correctly
  • Verify HitPay webhook is configured for real-time updates
  • Check browser console for network errors