home / skills / amnadtaowsoam / cerebraskills / 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-integrationReview the files below or copy the command above to add this skill to your agents.
---
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/)
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.
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.
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.