home / skills / amnadtaowsoam / cerebraskills / promptpay-integration

promptpay-integration skill

/90-thai-integrations/promptpay-integration

This skill helps you implement PromptPay QR code payments in Thailand, supporting static and dynamic codes for instant, bank-wide transactions.

npx playbooks add skill amnadtaowsoam/cerebraskills --skill promptpay-integration

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.3 KB
---
name: PromptPay Integration
description: QR Code payment integration for Thailand's PromptPay system, supporting both Static and Dynamic QR codes
---

# PromptPay Integration

## Overview

PromptPay is Thailand's standard QR Code payment system developed by NPCI under the Bank of Thailand's supervision. It supports receiving payments through all mobile banking apps using the EMVCo standard QR Code format.

## Why This Matters

- **Universal**: Supported by all banks in Thailand
- **No Fee**: No transaction fees for personal accounts
- **Instant**: Funds received immediately
- **Mobile First**: Optimized for mobile commerce

---

## Core Concepts

### 1. QR Code Types

| Type | Use Case | Amount |
|------|----------|--------|
| Static QR | General stores, donations | Payer enters amount |
| Dynamic QR | E-commerce, billing | Pre-specified amount |

### 2. Generate Static QR (PromptPay ID)

```typescript
// lib/promptpay.ts
import QRCode from 'qrcode';
import crc from 'crc';

interface PromptPayConfig {
  promptPayId: string;  // Phone number or National ID
  amount?: number;
  ref1?: string;
  ref2?: string;
}

function generatePromptPayPayload(config: PromptPayConfig): string {
  const { promptPayId, amount, ref1, ref2 } = config;

  // Determine ID type
  const isPhoneNumber = promptPayId.length <= 10;
  const formattedId = isPhoneNumber
    ? `0066${promptPayId.replace(/^0/, '')}` // Convert to international format
    : promptPayId; // National ID (13 digits)

  const idType = isPhoneNumber ? '01' : '02'; // 01=phone, 02=national ID

  // Build EMVCo payload
  let payload = '';

  // Payload Format Indicator
  payload += '000201';

  // Point of Initiation Method (11=static, 12=dynamic)
  payload += amount ? '010212' : '010211';

  // Merchant Account Information (PromptPay)
  const merchantInfo =
    `0016A000000677010111${idType}${formattedId.length.toString().padStart(2, '0')}${formattedId}`;
  payload += `29${merchantInfo.length.toString().padStart(2, '0')}${merchantInfo}`;

  // Transaction Currency (THB = 764)
  payload += '5303764';

  // Transaction Amount (if dynamic QR)
  if (amount) {
    const amountStr = amount.toFixed(2);
    payload += `54${amountStr.length.toString().padStart(2, '0')}${amountStr}`;
  }

  // Country Code
  payload += '5802TH';

  // Additional Data (Bill Payment)
  if (ref1 || ref2) {
    let additionalData = '';
    if (ref1) {
      additionalData += `01${ref1.length.toString().padStart(2, '0')}${ref1}`;
    }
    if (ref2) {
      additionalData += `02${ref2.length.toString().padStart(2, '0')}${ref2}`;
    }
    payload += `62${additionalData.length.toString().padStart(2, '0')}${additionalData}`;
  }

  // CRC placeholder
  payload += '6304';

  // Calculate and append CRC
  const crcValue = crc.crc16xmodem(payload).toString(16).toUpperCase().padStart(4, '0');
  payload += crcValue;

  return payload;
}

// Generate QR Code image
export async function generatePromptPayQR(config: PromptPayConfig): Promise<string> {
  const payload = generatePromptPayPayload(config);

  const qrDataUrl = await QRCode.toDataURL(payload, {
    errorCorrectionLevel: 'M',
    type: 'image/png',
    width: 300,
    margin: 2,
  });

  return qrDataUrl;
}

// Usage
const qrCode = await generatePromptPayQR({
  promptPayId: '0812345678',
  amount: 150.00,
  ref1: 'INV-2024-001',
});
```

### 3. Biller Payment API (PromptPay Bill Payment)

```typescript
// For businesses registered as Biller with banks
// Use bank's direct API, e.g., SCB, KBANK, BBL

// SCB PromptPay Bill Payment API
interface SCBBillPaymentRequest {
  billerCode: string;      // Biller code registered with SCB
  reference1: string;      // Reference 1 (order ID)
  reference2?: string;     // Reference 2 (optional)
  amount: number;
  expiryDate?: string;     // QR expiry (ISO 8601)
}

async function createSCBPromptPayBill(request: SCBBillPaymentRequest): Promise<BillPaymentResponse> {
  const token = await getSCBAccessToken();

  const response = await fetch('https://api.scb.co.th/v1/payment/billpayment/qrcode', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json',
      'resourceOwnerId': process.env.SCB_API_KEY,
      'requestUId': generateUUID(),
    },
    body: JSON.stringify({
      billerCode: request.billerCode,
      reference1: request.reference1,
      reference2: request.reference2 || '',
      amount: request.amount.toFixed(2),
      expiryDate: request.expiryDate,
    }),
  });

  const data = await response.json();

  return {
    qrCode: data.data.qrRawData,
    qrImage: data.data.qrImage,
    transactionId: data.data.transactionId,
    expiryTime: data.data.expiryTime,
  };
}
```

### 4. Payment Confirmation Webhook

```typescript
// api/webhooks/promptpay.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';

interface PromptPayWebhookPayload {
  transactionId: string;
  amount: number;
  reference1: string;
  reference2: string;
  sendingBank: string;
  payerAccountNumber: string;  // Masked
  payerName: string;
  transactionDateTime: string;
  status: 'SUCCESS' | 'FAILED';
}

export async function POST(req: NextRequest) {
  // Verify webhook signature (depends on provider)
  const signature = req.headers.get('x-signature');
  const body = await req.text();

  const expectedSignature = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');

  if (signature !== expectedSignature) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const payload: PromptPayWebhookPayload = JSON.parse(body);

  if (payload.status === 'SUCCESS') {
    // Update order status
    await prisma.order.update({
      where: { id: payload.reference1 },
      data: {
        paymentStatus: 'PAID',
        paidAt: new Date(payload.transactionDateTime),
        paymentTransactionId: payload.transactionId,
        paymentMethod: 'PROMPTPAY',
      },
    });

    // Trigger fulfillment
    await queue.add('process-order', { orderId: payload.reference1 });

    // Send confirmation to customer
    await notificationService.sendPaymentConfirmation(payload.reference1);
  }

  return NextResponse.json({ received: true });
}
```

### 5. Payment Verification (Polling)

```typescript
// For cases without webhook
async function pollPaymentStatus(
  transactionId: string,
  maxAttempts: number = 60,
  intervalMs: number = 5000
): Promise<PaymentStatus> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const status = await checkPaymentStatus(transactionId);

    if (status === 'SUCCESS') {
      return status;
    }

    if (status === 'FAILED' || status === 'EXPIRED') {
      throw new PaymentError(status);
    }

    await sleep(intervalMs);
  }

  throw new PaymentError('TIMEOUT');
}

// Bank API example (SCB)
async function checkPaymentStatus(transactionId: string): Promise<string> {
  const token = await getSCBAccessToken();

  const response = await fetch(
    `https://api.scb.co.th/v1/payment/billpayment/transactions/${transactionId}`,
    {
      headers: {
        'Authorization': `Bearer ${token}`,
        'resourceOwnerId': process.env.SCB_API_KEY,
      },
    }
  );

  const data = await response.json();
  return data.data.status;
}
```

### 6. React Component

```tsx
// components/PromptPayQR.tsx
'use client';

import { useState, useEffect } from 'react';
import Image from 'next/image';

interface PromptPayQRProps {
  orderId: string;
  amount: number;
  onPaymentComplete: () => void;
}

export function PromptPayQR({ orderId, amount, onPaymentComplete }: PromptPayQRProps) {
  const [qrCode, setQrCode] = useState<string | null>(null);
  const [status, setStatus] = useState<'loading' | 'pending' | 'success' | 'expired'>('loading');
  const [expiresAt, setExpiresAt] = useState<Date | null>(null);

  useEffect(() => {
    // Generate QR Code
    fetch('/api/payments/promptpay/generate', {
      method: 'POST',
      body: JSON.stringify({ orderId, amount }),
    })
      .then(res => res.json())
      .then(data => {
        setQrCode(data.qrImage);
        setExpiresAt(new Date(data.expiresAt));
        setStatus('pending');
      });
  }, [orderId, amount]);

  // Poll for payment status
  useEffect(() => {
    if (status !== 'pending') return;

    const interval = setInterval(async () => {
      const res = await fetch(`/api/payments/promptpay/status/${orderId}`);
      const data = await res.json();

      if (data.status === 'SUCCESS') {
        setStatus('success');
        onPaymentComplete();
        clearInterval(interval);
      }
    }, 5000);

    return () => clearInterval(interval);
  }, [status, orderId, onPaymentComplete]);

  if (status === 'loading') {
    return <div className="animate-pulse">Generating QR Code...</div>;
  }

  if (status === 'success') {
    return (
      <div className="text-center text-green-600">
        <CheckCircle className="w-16 h-16 mx-auto" />
        <p className="mt-2">Payment successful!</p>
      </div>
    );
  }

  return (
    <div className="text-center">
      <h3 className="text-lg font-semibold mb-4">Scan to pay</h3>

      {qrCode && (
        <Image
          src={qrCode}
          alt="PromptPay QR Code"
          width={300}
          height={300}
          className="mx-auto border rounded-lg"
        />
      )}

      <p className="mt-4 text-2xl font-bold">ā¸ŋ{amount.toLocaleString()}</p>

      <p className="mt-2 text-sm text-gray-500">
        Reference: {orderId}
      </p>

      {expiresAt && (
        <CountdownTimer expiresAt={expiresAt} onExpire={() => setStatus('expired')} />
      )}

      <div className="mt-4 flex justify-center gap-2">
        <Image src="/banks/scb.png" alt="SCB" width={40} height={40} />
        <Image src="/banks/kbank.png" alt="KBank" width={40} height={40} />
        <Image src="/banks/bbl.png" alt="BBL" width={40} height={40} />
        <span className="text-sm text-gray-500 self-center">and other banks</span>
      </div>
    </div>
  );
}
```

## Quick Start

1. **Generate basic QR Code:**
   ```typescript
   const qr = await generatePromptPayQR({
     promptPayId: '0812345678',
     amount: 100,
   });
   ```

2. **Register as Biller** (for businesses):
   - Contact bank (SCB, KBANK, BBL)
   - Submit company registration documents
   - Receive Biller Code and API credentials

3. **Implement webhook handler**

4. **Test with real banks** (sandbox environment)

## Production Checklist

- [ ] QR Code generation follows EMVCo standard
- [ ] CRC checksum is correct
- [ ] Webhook signature verification
- [ ] Idempotent payment processing
- [ ] Payment timeout handling
- [ ] Duplicate payment detection
- [ ] Refund flow implemented
- [ ] Error handling and logging
- [ ] Bank reconciliation process

## Anti-patterns

1. **Not verifying webhook signature**: Risk of fake payment notifications
2. **No idempotency**: May process payment twice
3. **Hardcoding PromptPay ID**: Use environment variables
4. **No expiry time**: Dynamic QR should have expiry time

## Integration Points

- **Banks**: SCB, KBANK, BBL Open APIs
- **Payment Gateways**: 2C2P, Omise (supports PromptPay)
- **E-commerce**: Shopify, WooCommerce plugins

## Further Reading

- [BOT PromptPay Standard](https://www.bot.or.th/Thai/PaymentSystems/PSServices/promptpay)
- [EMVCo QR Code Specification](https://www.emvco.com/emv-technologies/qrcodes/)
- [SCB Open Banking APIs](https://developer.scb/)

Overview

This skill provides a ready-to-use PromptPay QR code payment integration for Thailand, supporting both Static and Dynamic EMVCo-compliant QR codes and bank biller APIs. It includes payload generation, QR image creation, webhook handling, polling for payment status, and a React component for front-end display. Implementations are in Python-compatible patterns and examples are provided for common bank APIs and flows.

How this skill works

The integration builds EMVCo-formatted PromptPay payloads (including ID type, amount, country, and CRC) and renders PNG data URLs or bank-provided QR images. For business use it shows how to create biller QR transactions via bank APIs, then confirm payments via webhooks or polling. The package also demonstrates validating webhook signatures, updating orders, queuing fulfillment, and polling transaction endpoints for status.

When to use it

  • Accept instant mobile payments in Thailand via PromptPay
  • Generate static QR codes for storefronts and donations
  • Create dynamic QR codes for e-commerce invoices and one-time bills
  • Integrate with bank biller APIs (SCB, KBank, BBL) for enterprise flows
  • Handle payment confirmation where webhooks are unavailable using polling

Best practices

  • Follow EMVCo payload structure and compute CRC16-XMODEM for payload integrity
  • Use dynamic QR with expiry and reference fields for invoicing and reconciliation
  • Verify webhook signatures and design idempotent handlers to avoid double processing
  • Keep PromptPay IDs and API keys in environment variables, never hardcode
  • Implement timeout, retry, and duplicate detection for robust payment handling

Example use cases

  • Point-of-sale: print or display static PromptPay QR for walk-in customers
  • E-commerce checkout: create dynamic QR tied to order ID and amount, poll or await webhook
  • Biller integration: register with a bank, create QR via bank API, and deliver QR image to customer
  • Subscription or invoice payment: include reference fields for reconciling payments automatically
  • Mobile app: embed QR image and poll transaction status until success or expiry

FAQ

Do I need to be a registered biller to use PromptPay QR?

No. Anyone can use static QR codes with a PromptPay ID. Registering as a biller with a bank is required for bank-managed dynamic QR workflows and bulk reconciliation features.

How do I ensure the QR payload is valid?

Follow the EMVCo format, include correct ID type, currency, optional amount, additional data, append '6304' then compute CRC16-XMODEM and append its hex value.

Which confirmation method is recommended — webhook or polling?

Webhooks are preferred for real-time, server-driven confirmation and lower API usage. Polling is a fallback when the bank or environment does not support webhooks.