One-time payments are single, non-recurring transactions where customers pay once for a product or service. The customer selects a HitPay payment method, completes the payment via QR code or redirect, and the transaction is recorded in Stripe.
How It Works
This integration connects Stripe’s Payment Element with HitPay’s Payment Request API, allowing customers to pay using local payment methods while keeping all transaction records in Stripe.
Key Concepts
Component Purpose Stripe PaymentIntent Tracks the payment intent on Stripe’s side. Remains “incomplete” for external payments. HitPay Payment Request Processes the actual payment via local methods (PayNow, ShopeePay, etc.) and generates QR codes. Stripe Payment Records Records the external HitPay payment back in Stripe for unified reporting and reconciliation.
API References
Payment Flow
API Calls Summary
Step API Endpoint / Method Purpose 1 Stripe paymentIntents.create()Create PaymentIntent for checkout 2 HitPay POST /payment-requestsCreate payment request & generate QR code 3 HitPay Webhook to your server Notify payment completion 4 Stripe paymentMethods.create()Create CPM PaymentMethod 5 Stripe paymentRecords.reportPayment()Record payment for dashboard visibility
Create Custom Payment Methods on Stripe Dashboard
Create Configuration File
Server-side Map your Stripe CPM Type IDs to HitPay payment methods: // config/payment-methods.ts
interface CustomPaymentMethodConfig {
id : string ; // Stripe CPM Type ID (cpmt_xxx)
hitpayMethod : string ; // HitPay payment method
displayName : string ;
}
export const CUSTOM_PAYMENT_METHODS : CustomPaymentMethodConfig [] = [
{
id: 'cpmt_YOUR_PAYNOW_ID' , // Replace with your CPM Type ID
hitpayMethod: 'paynow_online' ,
displayName: 'PayNow' ,
},
{
id: 'cpmt_YOUR_SHOPEEPAY_ID' ,
hitpayMethod: 'shopee_pay' ,
displayName: 'ShopeePay' ,
},
{
id: 'cpmt_YOUR_GRABPAY_ID' ,
hitpayMethod: 'grabpay' ,
displayName: 'GrabPay' ,
},
];
export function getHitpayMethod ( cpmTypeId : string ) : string | undefined {
return CUSTOM_PAYMENT_METHODS . find ( pm => pm . id === cpmTypeId )?. hitpayMethod ;
}
Setup Stripe Client
Client-side Load Stripe.js with the beta flag and configure the Payment Element with your CPMs: // lib/stripe-client.ts
import { loadStripe } from '@stripe/stripe-js' ;
export const stripePromise = loadStripe (
process . env . NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ! ,
{
betas: [ 'custom_payment_methods_beta_1' ],
}
);
// Add CPMs to Elements provider
import { CUSTOM_PAYMENT_METHODS } from '@/config/payment-methods' ;
const elementsOptions = {
clientSecret ,
customPaymentMethods: CUSTOM_PAYMENT_METHODS . map ( pm => ({
id: pm . id ,
options: { type: 'static' as const },
})),
};
< Elements stripe = { stripePromise } options = { elementsOptions } >
< CheckoutForm />
</ Elements >
Add CPM to Elements Learn more about configuring Custom Payment Methods in the Payment Element.
Step 2: Create Payment Request
Configure Environment Variables
Server-side Set up the required API keys and configuration for both Stripe and HitPay. These credentials authenticate your server with both payment platforms. # 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_BASE_URL = https://your-domain.com
Never expose STRIPE_SECRET_KEY or HITPAY_API_KEY to the client.
Create HitPay Payment Request
Server-side When a user selects a CPM in the Payment Element, create a HitPay payment request and display the QR code: // app/api/hitpay/create/route.ts
import { NextResponse } from 'next/server' ;
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 { amount , currency , paymentMethod , paymentIntentId } = await request . json ();
const baseUrl = process . env . NEXT_PUBLIC_BASE_URL ;
const response = await fetch ( ` ${ HITPAY_API_URL } /payment-requests` , {
method: 'POST' ,
headers: {
'Content-Type' : 'application/json' ,
'X-BUSINESS-API-KEY' : process . env . HITPAY_API_KEY ! ,
},
body: JSON . stringify ({
amount: ( amount / 100 ). toFixed ( 2 ), // Convert cents to dollars
currency ,
payment_methods: [ paymentMethod ],
reference_number: paymentIntentId ,
webhook: ` ${ baseUrl } /api/hitpay/webhook` ,
generate_qr: true ,
}),
});
const data = await response . json ();
return NextResponse . json ({
paymentRequestId: data . id ,
qrCode: data . qr_code_data ?. qr_code ,
checkoutUrl: data . url ,
});
}
Displaying QR Codes: The generate_qr: true parameter returns QR code data in qr_code_data.qr_code. For detailed guidance on rendering QR codes and handling different payment methods, see our Embedded QR Code Payments guide.
Embedded QR Code Payments Learn how to embed and display QR codes for PayNow, ShopeePay, and other supported payment methods.
Display QR Code in Payment Element
Client-side Render the QR code in your checkout form when a customer selects a HitPay payment method. The customer scans the QR code with their mobile app to complete the payment. // components/CheckoutForm.tsx
'use client' ;
import { useState } from 'react' ;
import { PaymentElement } from '@stripe/react-stripe-js' ;
import { QRCodeSVG } from 'qrcode.react' ;
import { CUSTOM_PAYMENT_METHODS , getHitpayMethod } from '@/config/payment-methods' ;
export function CheckoutForm ({ amount , currency , paymentIntentId }) {
const [ qrCode , setQrCode ] = useState < string | null >( null );
const [ selectedCpm , setSelectedCpm ] = useState < string | null >( null );
const handlePaymentElementChange = async ( event : any ) => {
const paymentType = event . value ?. type ;
// Check if selected payment method is a CPM
const isCpm = CUSTOM_PAYMENT_METHODS . some ( pm => pm . id === paymentType );
if ( isCpm ) {
setSelectedCpm ( paymentType );
// Create HitPay payment request
const hitpayMethod = getHitpayMethod ( paymentType );
const response = await fetch ( '/api/hitpay/create' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
amount ,
currency ,
paymentMethod: hitpayMethod ,
paymentIntentId ,
}),
});
const data = await response . json ();
setQrCode ( data . qrCode );
} else {
setSelectedCpm ( null );
setQrCode ( null );
}
};
return (
< div >
< PaymentElement onChange = { handlePaymentElementChange } />
{ qrCode && (
< div className = "qr-container" >
< h3 > Scan to Pay </ h3 >
< QRCodeSVG value = { qrCode } size = { 256 } />
< p > Waiting for payment... </ p >
</ div >
) }
</ div >
);
}
Step 3: Record Payment Confirmation
Once the customer completes payment via HitPay, you need to:
Listen for payment confirmation from HitPay (via webhook or polling)
Record the payment in Stripe using the Payment Records API
This ensures all HitPay transactions appear in your Stripe Dashboard alongside native Stripe payments, enabling unified reporting and easier reconciliation.
Why record payments in Stripe? Without this step, HitPay payments would only appear in your HitPay Dashboard. By recording them via Stripe’s Payment Records API, you get a single source of truth for all transactions - cards, PayNow, ShopeePay, and more - all visible in one place.
Stripe Payment Records API Learn how Payment Records work and how they appear in your Stripe Dashboard.
Listen for Payment Confirmation
Server-side Use webhooks for production. HitPay automatically sends a POST request to your server when payment is completed - no polling required.How it works:
Customer completes payment via HitPay (scans QR, etc.)
HitPay sends webhook to your /api/hitpay/webhook endpoint
Your server verifies the signature and confirms payment status
Your server creates a Payment Record in Stripe
Transaction now appears in Stripe Dashboard for reconciliation
Advantage Description Reliable Payments recorded even if user closes browser Real-time Instant notification on payment completion Secure HMAC signature verification prevents fraud Unified Dashboard All transactions visible in Stripe
HitPay Webhooks Guide Configure webhooks in your HitPay Dashboard.
// app/api/hitpay/webhook/route.ts
import { NextRequest , NextResponse } from 'next/server' ;
import crypto from 'crypto' ;
import Stripe from 'stripe' ;
const stripe = new Stripe ( process . env . STRIPE_SECRET_KEY ! , {
apiVersion: '2024-12-18.acacia;custom_payment_methods_beta=v1' ,
});
function verifySignature ( payload : Record < string , string >, hmac : string ) : boolean {
const salt = process . env . HITPAY_SALT ! ;
const sortedKeys = Object . keys ( payload ). filter ( k => k !== 'hmac' ). sort ();
const signatureString = sortedKeys . map ( k => ` ${ k }${ payload [ k ] } ` ). join ( '' );
const calculated = crypto . createHmac ( 'sha256' , salt ). update ( signatureString ). digest ( 'hex' );
return calculated === hmac ;
}
export async function POST ( request : NextRequest ) {
const contentType = request . headers . get ( 'content-type' ) || '' ;
let payload : Record < string , string >;
if ( contentType . includes ( 'application/x-www-form-urlencoded' )) {
const formData = await request . formData ();
payload = Object . fromEntries ( formData . entries ()) as Record < string , string >;
} else {
payload = await request . json ();
}
// Verify webhook signature
if ( ! verifySignature ( payload , payload . hmac )) {
return NextResponse . json ({ error: 'Invalid signature' }, { status: 401 });
}
// Only process completed payments
if ( payload . status !== 'completed' ) {
return NextResponse . json ({ received: true });
}
const paymentIntentId = payload . reference_number ;
const paymentIntent = await stripe . paymentIntents . retrieve ( paymentIntentId );
// Idempotency check
if ( paymentIntent . metadata ?. external_payment_status === 'completed' ) {
return NextResponse . json ({ received: true , message: 'Already recorded' });
}
// Create PaymentMethod with CPM type
const cpmTypeId = paymentIntent . metadata ?. cpm_type_id || 'cpmt_xxx' ;
const paymentMethod = await stripe . paymentMethods . create ({
type: 'custom' ,
custom: { type: cpmTypeId },
});
// Record payment via Payment Records API
const timestamp = Math . floor ( Date . now () / 1000 );
await stripe . paymentRecords . reportPayment ({
amount_requested: {
value: paymentIntent . amount ,
currency: paymentIntent . currency ,
},
payment_method_details: { payment_method: paymentMethod . id },
processor_details: {
type: 'custom' ,
custom: { payment_reference: payload . payment_request_id },
},
initiated_at: timestamp ,
customer_presence: 'on_session' ,
outcome: 'guaranteed' ,
guaranteed: { guaranteed_at: timestamp },
metadata: {
payment_intent_id: paymentIntentId ,
hitpay_payment_id: payload . payment_id ,
},
});
// Update PaymentIntent metadata
await stripe . paymentIntents . update ( paymentIntentId , {
metadata: { external_payment_status: 'completed' },
});
return NextResponse . json ({ received: true });
}
Not recommended for production. Polling may miss payments if the user closes their browser before completion is detected. Use webhooks instead.
Polling is useful for local development when you don’t have a public webhook URL. How it works:
Frontend polls /api/payment/check-status every 3 seconds
Backend checks HitPay API for payment status
When completed, backend creates a Payment Record in Stripe
Frontend redirects to success page
// app/api/payment/check-status/route.ts
import { NextResponse } from 'next/server' ;
import Stripe from 'stripe' ;
const stripe = new Stripe ( process . env . STRIPE_SECRET_KEY ! , {
apiVersion: '2024-12-18.acacia;custom_payment_methods_beta=v1' ,
});
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 { paymentRequestId , paymentIntentId , cpmTypeId } = await request . json ();
// Check HitPay payment status
const statusResponse = await fetch (
` ${ HITPAY_API_URL } /payment-requests/ ${ paymentRequestId } ` ,
{ headers: { 'X-BUSINESS-API-KEY' : process . env . HITPAY_API_KEY ! } }
);
const statusData = await statusResponse . json ();
if ( statusData . status !== 'completed' ) {
return NextResponse . json ({ status: statusData . status });
}
// Record payment in Stripe (same logic as webhook)
const paymentIntent = await stripe . paymentIntents . retrieve ( paymentIntentId );
if ( paymentIntent . metadata ?. external_payment_status === 'completed' ) {
return NextResponse . json ({ status: 'completed' , alreadyRecorded: true });
}
const paymentMethod = await stripe . paymentMethods . create ({
type: 'custom' ,
custom: { type: cpmTypeId },
});
const timestamp = Math . floor ( Date . now () / 1000 );
await stripe . paymentRecords . reportPayment ({
amount_requested: {
value: paymentIntent . amount ,
currency: paymentIntent . currency ,
},
payment_method_details: { payment_method: paymentMethod . id },
processor_details: {
type: 'custom' ,
custom: { payment_reference: paymentRequestId },
},
initiated_at: timestamp ,
customer_presence: 'on_session' ,
outcome: 'guaranteed' ,
guaranteed: { guaranteed_at: timestamp },
});
await stripe . paymentIntents . update ( paymentIntentId , {
metadata: { external_payment_status: 'completed' },
});
return NextResponse . json ({ status: 'completed' });
}
Frontend polling: // Poll every 3 seconds until payment is completed
const poll = async () => {
const response = await fetch ( '/api/payment/check-status' , {
method: 'POST' ,
body: JSON . stringify ({ paymentRequestId , paymentIntentId , cpmTypeId }),
});
const { status } = await response . json ();
if ( status === 'completed' ) {
window . location . href = '/success' ;
} else {
setTimeout ( poll , 3000 );
}
};
Testing
Sandbox Testing
Set NEXT_PUBLIC_HITPAY_ENV=sandbox in your environment
Use HitPay sandbox API credentials
When a QR code is displayed, HitPay provides a “Complete Mock Payment” link
Click this link to simulate a successful payment
Production Testing
Switch to production credentials
Use real payment apps to scan QR codes
Verify payments appear in both HitPay and Stripe dashboards
PaymentIntents will show as “Incomplete” in Stripe - this is expected. External payments are tracked via Payment Records (prec_* IDs).
Troubleshooting
CPM not showing in Payment Element
Verify the CPM Type is enabled in Stripe Dashboard
Check that the CPM ID in your config matches the Dashboard
Ensure you’re loading Stripe.js with custom_payment_methods_beta_1
Not all HitPay methods support QR codes (e.g., GrabPay uses redirect)
Check the checkoutUrl fallback is being displayed
Verify the payment method is enabled in HitPay Dashboard
HitPay 422 validation error
The payment method may not be enabled in your HitPay account
Some methods aren’t available in sandbox mode
Check the error message for specific details
Payment not recording in Stripe
Check server logs for Payment Records API errors
Verify your Stripe API version includes the beta flag
The payment still succeeded if HitPay shows completed