> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hitpayapp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# One-Time Payments

> Implement one-time payments with Stripe Custom Payment Methods and HitPay

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

<CardGroup cols={2}>
  <Card title="HitPay Payment Request API" icon="code" href="https://docs.hitpayapp.com/apis/reference/create-payment-request">
    Create payment requests and generate QR codes for local payment methods.
  </Card>

  <Card title="HitPay Embedded QR Payments" icon="qrcode" href="https://docs.hitpayapp.com/apis/guide/embedded-qr-code-payments">
    Embed QR codes directly in your checkout for seamless mobile payments.
  </Card>
</CardGroup>

***

## Payment Flow

```mermaid theme={"system"}
sequenceDiagram
    participant User
    participant Frontend
    participant Backend
    participant Stripe as Stripe API
    participant HitPay as HitPay API

    User->>Frontend: Add to cart & checkout
    Frontend->>Backend: POST /api/create-payment-intent

    rect rgb(230, 240, 255)
    Note over Backend,Stripe: Stripe API
    Backend->>Stripe: paymentIntents.create()
    Stripe-->>Backend: clientSecret
    end

    Backend-->>Frontend: clientSecret
    Frontend->>User: Show Payment Element (with CPMs)
    User->>Frontend: Select CPM (e.g., PayNow)
    Frontend->>Backend: POST /api/hitpay/create

    rect rgb(255, 240, 230)
    Note over Backend,HitPay: HitPay API
    Backend->>HitPay: POST /payment-requests
    HitPay-->>Backend: QR code + paymentRequestId
    end

    Backend-->>Frontend: QR code data
    Frontend->>User: Display QR code
    User->>HitPay: Scan & pay via mobile app

    rect rgb(255, 240, 230)
    Note over HitPay,Backend: HitPay Webhook
    HitPay->>Backend: POST /api/hitpay/webhook
    end

    rect rgb(230, 240, 255)
    Note over Backend,Stripe: Stripe API
    Backend->>Stripe: paymentMethods.create()
    Backend->>Stripe: paymentRecords.reportPayment()
    end

    Backend-->>HitPay: 200 OK
    Frontend->>User: Redirect to success page
```

## Step 1: Configure Custom Payment Methods

Register each HitPay payment method as a Custom Payment Method (CPM) type in your Stripe Dashboard, map those CPM Type IDs to their HitPay method identifiers in a config file, and load them into the Stripe Elements provider so they appear as options in your checkout.

<Steps>
  <Step title="Create Custom Payment Methods on Stripe Dashboard">
    <Badge color="gray">Stripe Dashboard</Badge>

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

    After creating the custom payment method, the Dashboard displays the custom payment method ID (beginning with cpmt\_) that you need for the next step.

    <Card title="Create CPM in Stripe Dashboard" icon="stripe" href="https://docs.stripe.com/payments/payment-element/custom-payment-methods#create-cpm-in-dashboard">
      Follow Stripe's guide to create Custom Payment Method types and get your CPM Type IDs.
    </Card>

    <Card title="Download Payment Icons" icon="download" href="/files/payment_icons.zip">
      Download official HitPay payment method icons (PayNow, ShopeePay, GrabPay, FPX, and more) optimized for Stripe Custom Payment Methods.
    </Card>
  </Step>

  <Step title="Create Configuration File">
    <Badge color="gray">Server-side</Badge>

    This configuration file maps the Custom Payment Methods you created in the Stripe Dashboard to their corresponding HitPay payment method identifiers. Each entry links a Stripe CPM Type ID (e.g. `cpmt_xxx`) to the HitPay API method name used when creating a payment request.

    See the [HitPay Payment Methods Reference](/apis/guide/payment-methods-reference) for the full list of supported payment method names and their identifiers.

    The `chargeAutomatically` flag indicates whether a payment method supports tokenized or recurring payments — these methods can be used to charge customers automatically without requiring re-authorization each time.

    The code below is a sample — adapt it to the payment methods you've configured:

    <Accordion title="config/payment-methods.ts">
      ```typescript theme={"system"}
      // config/payment-methods.ts

      interface CustomPaymentMethodConfig {
        id: string;                      // Stripe CPM Type ID (cpmt_xxx)
        hitpayMethod: string;            // HitPay one-time payment method
        hitpayRecurringMethod?: string;  // Example only: used when the recurring method name differs from the one-time name
        displayName: string;
        chargeAutomatically: boolean;    // Supports auto-charge subscriptions via HitPay tokenization
      }

      export const CUSTOM_PAYMENT_METHODS: CustomPaymentMethodConfig[] = [
        {
          id: 'cpmt_YOUR_PAYNOW_ID',      // Replace with your CPM Type 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,
        },
      ];

      export function getHitpayMethod(cpmTypeId: string): string | undefined {
        return CUSTOM_PAYMENT_METHODS.find(pm => pm.id === cpmTypeId)?.hitpayMethod;
      }
      ```
    </Accordion>

    <Tip>
      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.
    </Tip>
  </Step>

  <Step title="Add Custom Payment Method Type to Stripe Elements Configuration">
    <Badge color="gray">Client-side</Badge>

    Next, add the custom payment method type to your Stripe Elements configuration. In your `checkout.js` file where you initialise Stripe Elements, specify the `customPaymentMethods` to add to the Payment Element. Provide the custom payment method ID from the previous step, the `options.type` and an optional subtitle.

    Load Stripe.js with the beta flag:

    <Accordion title="lib/stripe-client.ts">
      ```typescript theme={"system"}
      // 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'],
        }
      );
      ```
    </Accordion>

    <Accordion title="Add CPMs to Elements provider">
      ```tsx theme={"system"}
      // 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>
      ```
    </Accordion>

    After loading, the Payment Element shows your custom payment method.

    <Card title="Add CPM to Elements" icon="stripe" href="https://docs.stripe.com/payments/payment-element/custom-payment-methods#add-cpm-to-elements">
      Learn more about configuring Custom Payment Methods in the Payment Element.
    </Card>
  </Step>
</Steps>

***

## Step 2: Display Payment on Stripe Payment Elements

When a customer selects a HitPay payment method in the Payment Element, your backend creates a HitPay payment request and returns a QR code or deep link for the customer to complete payment on their mobile device.

<Steps>
  <Step title="Configure Environment Variables">
    <Badge color="gray">Server-side</Badge>

    Set up the required API keys and configuration for both Stripe and HitPay. These credentials authenticate your server with both payment platforms.

    ```bash theme={"system"}
    # 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
    ```

    <Warning>
      Never expose `STRIPE_SECRET_KEY` or `HITPAY_API_KEY` to the client.
    </Warning>
  </Step>

  <Step title="Create HitPay Payment Request">
    <Badge color="gray">Server-side</Badge>

    When a user selects a CPM in the Payment Element, create a HitPay payment request and display the QR code:

    <Accordion title="app/api/hitpay/create/route.ts">
      ```typescript theme={"system"}
      // 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,
          directLinkUrl: data.direct_link?.direct_link_url,
          checkoutUrl: data.url,
        });
      }
      ```
    </Accordion>

    <Tip>
      **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.
    </Tip>

    <Note>
      **App-based methods (ShopeePay, GrabPay):** Some payment methods don't generate a QR code — instead, HitPay returns a `direct_link.direct_link_url` that opens the payment app directly. When `directLinkUrl` is present, redirect the customer to that URL instead of displaying a QR code.
    </Note>

    <Card title="Embedded QR Code Payments" icon="qrcode" href="/apis/guide/embedded-qr-code-payments">
      Learn how to embed and display QR codes for PayNow, ShopeePay, and other supported payment methods.
    </Card>
  </Step>

  <Step title="Display QR Code in Payment Element">
    <Badge color="gray">Client-side</Badge>

    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.

    <Accordion title="components/CheckoutForm.tsx">
      ```tsx theme={"system"}
      // 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();

            if (data.directLinkUrl) {
              // App-based method (e.g. ShopeePay, GrabPay) — redirect to payment app
              window.location.href = data.directLinkUrl;
            } else {
              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>
        );
      }
      ```
    </Accordion>
  </Step>
</Steps>

***

## Step 3: Record Payment Confirmation

Once the customer pays via HitPay, your backend receives a webhook, verifies the payment, and records it in Stripe using the Payment Records API — making it visible in your Stripe Dashboard alongside native card payments.

<Info>
  Without this step, HitPay payments 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 in one place.
</Info>

<Card title="Stripe Payment Records API" icon="stripe" href="https://docs.stripe.com/payments/payment-element/custom-payment-methods#record-payment">
  Learn how Payment Records work and how they appear in your Stripe Dashboard.
</Card>

<Steps>
  <Step title="Listen for Payment Confirmation">
    <Badge color="gray">Server-side</Badge>

    <Tabs>
      <Tab title="Webhooks (Recommended)">
        **Use webhooks for production.** HitPay automatically sends a POST request to your server when payment is completed.

        **How it works:**

        1. Customer completes payment via HitPay (scans QR, etc.)
        2. HitPay sends webhook to your `/api/hitpay/webhook` endpoint
        3. Your server verifies the signature and confirms payment status
        4. Your server creates a **Payment Record** in Stripe
        5. 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            |

        <Note>
          **Frontend completion detection:** Even when using webhooks for payment recording, your frontend still needs a way to know when the payment is complete so it can redirect the customer. The recommended pattern is to have the frontend **poll a status endpoint** (e.g., `/api/payment/check-status`) every few seconds — this endpoint checks whether the webhook has already recorded the payment. Use an **idempotency key** (e.g., `prec-{paymentRequestId}`) when creating the Payment Record to prevent double-recording if both webhook and polling race.
        </Note>

        <Card title="HitPay Webhooks Guide" icon="webhook" href="https://docs.hitpayapp.com/apis/guide/webhook">
          Configure webhooks in your HitPay Dashboard.
        </Card>

        <Accordion title="app/api/hitpay/webhook/route.ts">
          ```typescript theme={"system"}
          // 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 });
          }
          ```
        </Accordion>
      </Tab>

      <Tab title="Polling (Not Recommended)">
        <Warning>
          **Not recommended for production.** Polling may miss payments if the user closes their browser before completion is detected. Use webhooks instead.
        </Warning>

        Polling is useful for **local development** when you don't have a public webhook URL.

        **How it works:**

        1. Frontend polls `/api/payment/check-status` every 3 seconds
        2. Backend checks HitPay API for payment status
        3. When completed, backend creates a **Payment Record** in Stripe
        4. Frontend redirects to success page

        <Accordion title="app/api/payment/check-status/route.ts">
          ```typescript theme={"system"}
          // 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' });
          }
          ```
        </Accordion>

        **Frontend polling:**

        <Accordion title="Frontend polling snippet">
          ```tsx theme={"system"}
          // 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);
            }
          };
          ```
        </Accordion>
      </Tab>
    </Tabs>
  </Step>

  <Step title="Record the Payment to Your Stripe Account">
    <Badge color="gray">Server-side</Badge>

    Once HitPay confirms the payment is complete, call `stripe.paymentRecords.reportPayment()` to create a **Payment Record** (`prec_xxx`) in Stripe. This is what makes the transaction visible in your Stripe Dashboard alongside native card payments.

    <Info>
      The original PaymentIntent remains `"Incomplete"` — this is expected for external payments. The Payment Record is the canonical record of the transaction and what Stripe uses for revenue reporting.
    </Info>

    Each field in the payload serves a specific purpose:

    | Field                                        | Value                               | Why                                                         |
    | -------------------------------------------- | ----------------------------------- | ----------------------------------------------------------- |
    | `amount_requested`                           | PaymentIntent `amount` + `currency` | Ties the record to the original order amount                |
    | `payment_method_details.payment_method`      | Custom PaymentMethod ID (`pm_xxx`)  | Links the CPM type (PayNow, ShopeePay, etc.) to this record |
    | `processor_details.custom.payment_reference` | HitPay payment ID                   | External transaction reference for reconciliation           |
    | `initiated_at`                               | Unix timestamp                      | When the payment was initiated                              |
    | `customer_presence`                          | `'on_session'`                      | Customer was actively present at time of payment            |
    | `outcome`                                    | `'guaranteed'`                      | HitPay has confirmed settlement — funds are guaranteed      |
    | `guaranteed.guaranteed_at`                   | Unix timestamp                      | When settlement was confirmed by HitPay                     |
    | `metadata`                                   | HitPay IDs, PI ID                   | Searchable references for support and debugging             |

    <Accordion title="Record payment — stripe.paymentRecords.reportPayment()">
      ```typescript theme={"system"}
      // After HitPay confirms payment is completed:

      // 1. Create a PaymentMethod instance for your CPM type
      const paymentMethod = await stripe.paymentMethods.create({
        type: 'custom',
        custom: { type: 'cpmt_xxx' }, // your CPM Type ID from Stripe Dashboard
      });

      // 2. Report the payment — creates a prec_xxx record in Stripe
      const paymentRecord = await stripe.paymentRecords.reportPayment({
        amount_requested: {
          value: paymentIntent.amount,    // in cents, matches the original PaymentIntent
          currency: paymentIntent.currency,
        },
        payment_method_details: {
          payment_method: paymentMethod.id, // links the CPM type to this record
        },
        processor_details: {
          type: 'custom',
          custom: {
            payment_reference: hitpayPaymentId, // HitPay's payment ID for reconciliation
          },
        },
        initiated_at: Math.floor(Date.now() / 1000),
        customer_presence: 'on_session',
        outcome: 'guaranteed',
        guaranteed: { guaranteed_at: Math.floor(Date.now() / 1000) },
        metadata: {
          hitpay_payment_id: hitpayPaymentId,
          hitpay_payment_request_id: hitpayPaymentRequestId,
          stripe_payment_intent_id: paymentIntentId,
        },
      });

      // 3. Update PaymentIntent metadata for easy lookup
      await stripe.paymentIntents.update(paymentIntentId, {
        metadata: {
          external_payment_status: 'completed',
          stripe_payment_record_id: paymentRecord.id, // prec_xxx
        },
      });
      ```
    </Accordion>
  </Step>
</Steps>

***

## Testing

### Sandbox Testing

1. Set `NEXT_PUBLIC_HITPAY_ENV=sandbox` in your environment
2. Use HitPay sandbox API credentials
3. When a QR code is displayed, HitPay provides a "Complete Mock Payment" link
4. Click this link to simulate a successful payment

### Production Testing

1. Switch to production credentials
2. Use real payment apps to scan QR codes
3. Verify payments appear in both HitPay and Stripe dashboards

<Warning>
  PaymentIntents will show as "Incomplete" in Stripe - this is expected. External payments are tracked via Payment Records (`prec_*` IDs).
</Warning>

***

## FAQ

<AccordionGroup>
  <Accordion title="Why isn't my CPM showing in the 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`
  </Accordion>

  <Accordion title="Why isn't the QR code appearing?">
    * 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
  </Accordion>

  <Accordion title="Why am I getting a 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
  </Accordion>

  <Accordion title="Why isn't the payment 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
  </Accordion>
</AccordionGroup>
