This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
# SePay API Reference
Base URL: `https://my.sepay.vn/userapi/`
Rate Limit: 2 calls/second
## Transaction API
### List Transactions
```
GET /userapi/transactions/list
```
**Parameters:**
- `account_number` (string) - Bank account ID
- `transaction_date_min/max` (yyyy-mm-dd) - Date range
- `since_id` (integer) - Start from ID
- `limit` (integer) - Max 5000 per request
- `reference_number` (string) - Transaction reference
- `amount_in` (number) - Incoming amount
- `amount_out` (number) - Outgoing amount
**Response:**
```json
{
"status": 200,
"transactions": [{
"id": 92704,
"gateway": "Vietcombank",
"transaction_date": "2023-03-25 14:02:37",
"account_number": "0123499999",
"content": "payment content",
"transfer_type": "in",
"transfer_amount": 2277000,
"accumulated": 19077000,
"reference_number": "MBVCB.3278907687",
"bank_account_id": 123
}]
}
```
### Transaction Details
```
GET /userapi/transactions/details/{transaction_id}
```
### Count Transactions
```
GET /userapi/transactions/count
```
## Bank Account API
### List Bank Accounts
```
GET /userapi/bankaccounts/list
```
**Parameters:**
- `short_name` - Bank identifier
- `last_transaction_date_min/max` - Date range
- `since_id` - Starting account ID
- `limit` - Results per page (default 100)
- `accumulated_min/max` - Balance range
**Response:**
```json
{
"id": 123,
"account_holder_name": "NGUYEN VAN A",
"account_number": "0123456789",
"accumulated": 50000000,
"last_transaction": "2025-01-13 10:30:00",
"bank_short_name": "VCB",
"active": 1
}
```
### Account Details
```
GET /userapi/bankaccounts/details/{bank_account_id}
```
### Count Accounts
```
GET /userapi/bankaccounts/count
```
## Order-Based Virtual Account API
**Concept:** Each order gets unique VA with exact amount matching for automated confirmation.
**Flow:**
1. Create order → API generates unique VA
2. Display VA + QR to customer
3. Customer transfers to VA
4. Bank notifies SePay on success
5. SePay triggers webhook
6. Update order status
**Advantages:**
- Precision: VA accepts only exact amounts
- Independence: Each order has own VA (no content parsing)
- Security: VAs auto-cancel after success/expiration
- Integration: RESTful API
**Supported Banks:** BIDV and others (check docs for full list)
## Error Handling
**HTTP Status Codes:**
- 200 OK - Successful
- 201 Created - Resource created
- 400 Bad Request - Invalid parameters
- 401 Unauthorized - Invalid/missing auth
- 403 Forbidden - Insufficient permissions
- 404 Not Found - Resource not found
- 429 Too Many Requests - Rate limit exceeded
- 500 Internal Server Error - Server error
- 503 Service Unavailable - Temporarily unavailable
**Rate Limit Response:**
```json
{
"status": 429,
"error": "rate_limit_exceeded",
"message": "Too many requests"
}
```
Check `x-sepay-userapi-retry-after` header for retry timing.
## Best Practices
1. **Pagination:** Use `limit` and `since_id` for large datasets
2. **Date Ranges:** Query specific periods to reduce response size
3. **Rate Limiting:** Implement exponential backoff
4. **Error Handling:** Log all errors with context
5. **Caching:** Cache bank account lists
6. **Monitoring:** Track API response times and error rates
7. **Reconciliation:** Regular transaction matching

View File

@@ -0,0 +1,939 @@
# SePay Best Practices
Production-proven patterns for Vietnamese bank transfer payments via SePay/VietQR, covering transaction parsing, webhook handling, order matching, currency conversion, and error handling.
## Environment Configuration
### Required Environment Variables
```bash
# Core API
SEPAY_API_TOKEN=xxx # Bearer token for SePay API
SEPAY_WEBHOOK_API_KEY=xxx # API key for webhook authentication
SEPAY_API_URL=https://my.sepay.vn/userapi # Base URL (optional)
# Bank Account Details
SEPAY_ACCOUNT_NUMBER=0123456789 # Bank account for transfers
SEPAY_ACCOUNT_NAME=COMPANY_NAME # Account holder name
SEPAY_BANK_NAME=Vietcombank # Bank name (VietQR recognized)
```
### Product Pricing in VND
```typescript
// lib/sepay.ts
const VND_PRICES = {
engineer_kit: 2450000, // ~$100 USD
marketing_kit: 2450000, // ~$100 USD
combo: 3650000, // ~$149 USD
} as const;
const USD_TO_VND_RATE = 24500; // 1 USD ≈ 24,500 VND
```
## Transaction Content Format
### Standard Format
```
CLAUDEKIT {order-uuid}
```
Example: `CLAUDEKIT 4e4635f4-0478-4080-a5c5-48da91f97f1e`
### Team Checkout Format
```
TEAM{8-hex-chars}
```
Example: `TEAM4E4635F4`
### Why These Formats
- UUID ensures global uniqueness
- `CLAUDEKIT` prefix for easy visual identification
- Short team prefix fits bank memo limits
- Case-insensitive matching handles bank transformations
## QR Code Generation
### VietQR URL Pattern
```typescript
// lib/sepay.ts
export function generateVietQRUrl(
accountNumber: string,
bankName: string,
amount: number,
content: string
): string {
const params = new URLSearchParams({
acc: accountNumber,
bank: bankName,
amount: String(Math.floor(amount)), // Integer only
des: content,
});
return `https://qr.sepay.vn/img?${params.toString()}`;
}
```
### Usage Example
```typescript
const qrUrl = generateVietQRUrl(
process.env.SEPAY_ACCOUNT_NUMBER!,
process.env.SEPAY_BANK_NAME!,
2450000,
`CLAUDEKIT ${orderId}`
);
// Returns: https://qr.sepay.vn/img?acc=0123456789&bank=Vietcombank&amount=2450000&des=CLAUDEKIT+uuid
```
## Checkout API Implementation
### Standard SePay Checkout
```typescript
// app/api/checkout/sepay/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';
const checkoutSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
productType: z.enum(['engineer_kit', 'marketing_kit', 'combo']),
githubUsername: z.string().min(1),
couponCode: z.string().optional(),
vatInvoiceRequested: z.boolean().optional(),
taxId: z.string().regex(/^\d{10}$|^\d{13}$/).optional(), // 10 or 13 digits
});
export async function POST(request: Request) {
try {
const body = await request.json();
const data = checkoutSchema.parse(body);
// 1. Normalize email
const normalizedEmail = data.email.toLowerCase().trim();
// 2. Get base price
const originalAmount = VND_PRICES[data.productType];
let finalAmount = originalAmount;
let discountMetadata: Record<string, any> = { originalAmount };
// 3. CRITICAL: Apply discounts in correct order
// Step A: Apply coupon FIRST
if (data.couponCode) {
const couponResult = await validateCouponForVND(data.couponCode, originalAmount);
if (couponResult.valid) {
finalAmount = originalAmount - couponResult.discountAmountVND;
discountMetadata.couponCode = data.couponCode;
discountMetadata.couponDiscountAmount = couponResult.discountAmountVND;
discountMetadata.couponId = couponResult.couponId;
}
}
// Step B: Apply referral SECOND (on post-coupon amount)
const referralCode = getReferralCodeFromCookie(request);
if (referralCode) {
const referralResult = await calculateReferralDiscountVND(
referralCode,
finalAmount, // Post-coupon amount
normalizedEmail
);
if (referralResult.valid && referralResult.discountAmount > 0) {
// Validate calculation
if (referralResult.discountAmount <= 0) {
return NextResponse.json(
{ error: 'Invalid discount calculation' },
{ status: 400 }
);
}
finalAmount -= referralResult.discountAmount;
discountMetadata.referralCode = referralCode;
discountMetadata.referralDiscountAmount = referralResult.discountAmount;
discountMetadata.referrerId = referralResult.referrerId;
}
}
// 4. Validate final amount
if (finalAmount <= 0) {
return NextResponse.json(
{ error: 'Invalid final amount' },
{ status: 400 }
);
}
// 5. Encrypt sensitive data if VAT invoice requested
let encryptedTaxId: string | null = null;
if (data.vatInvoiceRequested && data.taxId) {
encryptedTaxId = await encrypt(data.taxId);
}
// 6. Create order record
const orderId = crypto.randomUUID();
const transactionContent = `CLAUDEKIT ${orderId}`;
const order = await db.insert(orders).values({
id: orderId,
email: normalizedEmail,
productType: data.productType,
amount: finalAmount,
currency: 'VND',
status: 'pending',
paymentProvider: 'sepay',
paymentId: transactionContent, // Used for matching
referredBy: discountMetadata.referrerId,
discountAmount: originalAmount - finalAmount,
metadata: JSON.stringify({
...discountMetadata,
githubUsername: data.githubUsername,
vatInvoiceRequested: data.vatInvoiceRequested,
encryptedTaxId,
}),
}).returning();
// 7. Generate payment instructions
const qrCode = generateVietQRUrl(
process.env.SEPAY_ACCOUNT_NUMBER!,
process.env.SEPAY_BANK_NAME!,
finalAmount,
transactionContent
);
return NextResponse.json({
orderId: order[0].id,
paymentMethod: 'bank_transfer',
payment: {
bankName: process.env.SEPAY_BANK_NAME,
accountNumber: process.env.SEPAY_ACCOUNT_NUMBER,
accountName: process.env.SEPAY_ACCOUNT_NAME,
amount: finalAmount,
currency: 'VND',
content: transactionContent,
qrCode,
instructions: [
'Open your banking app',
'Scan the QR code or transfer manually',
'Use the exact transfer content shown',
'Payment will be confirmed automatically',
],
},
statusCheckUrl: `/api/orders/${order[0].id}/status`,
});
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({ error: error.errors }, { status: 400 });
}
console.error('SePay checkout error:', error);
return NextResponse.json(
{ error: 'Failed to create checkout' },
{ status: 500 }
);
}
}
```
## Webhook Handling
### Webhook Authentication (Timing-Safe)
```typescript
// app/api/webhooks/sepay/route.ts
import { timingSafeEqual } from 'crypto';
import { NextResponse } from 'next/server';
function verifyWebhookAuth(request: Request): boolean {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return false;
const expectedKey = process.env.SEPAY_WEBHOOK_API_KEY!;
// Support both "Bearer" and "Apikey" formats
let providedKey: string;
if (authHeader.startsWith('Bearer ')) {
providedKey = authHeader.slice(7);
} else if (authHeader.startsWith('Apikey ')) {
providedKey = authHeader.slice(7);
} else {
return false;
}
// Timing-safe comparison to prevent timing attacks
try {
const expected = Buffer.from(expectedKey);
const provided = Buffer.from(providedKey);
if (expected.length !== provided.length) return false;
return timingSafeEqual(expected, provided);
} catch {
return false;
}
}
export async function POST(request: Request) {
// 1. Verify authentication
if (!verifyWebhookAuth(request)) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const payload = await request.json();
// 2. Extract event ID for idempotency
const eventId = String(payload.id || payload.transaction_id || Date.now());
// 3. Check for duplicate
const existingEvent = await db.select()
.from(webhookEvents)
.where(eq(webhookEvents.eventId, eventId))
.limit(1);
if (existingEvent.length > 0) {
console.log(`Duplicate SePay webhook ignored: ${eventId}`);
return NextResponse.json({ success: true });
}
// 4. Record event BEFORE processing (idempotency)
await db.insert(webhookEvents).values({
id: crypto.randomUUID(),
provider: 'sepay',
eventType: 'transaction',
eventId,
payload: JSON.stringify(payload),
processed: false,
});
try {
await processTransaction(payload);
await db.update(webhookEvents)
.set({ processed: true, processedAt: new Date() })
.where(eq(webhookEvents.eventId, eventId));
} catch (error) {
// Log error but return 200 to prevent retry loop
await db.update(webhookEvents)
.set({
processed: true,
processedAt: new Date(),
error: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(webhookEvents.eventId, eventId));
}
// Always return 200 to prevent SePay retries
return NextResponse.json({ success: true });
}
```
### Webhook Payload Structure
```typescript
interface SepayWebhookPayload {
id: number; // Transaction ID (unique key)
gateway: string; // Bank name (e.g., "Vietcombank")
transactionDate: string; // "2025-01-07 10:30:00"
accountNumber: string; // Account number
code?: string; // Optional payment code
content: string; // Transaction memo - CRITICAL for matching
transferType: 'in' | 'out'; // Only process 'in'
transferAmount: number; // Amount in VND
accumulated: number; // Balance after transaction
subAccount?: string;
referenceCode?: string;
description?: string;
}
```
## Order Matching Strategy
### Multi-Strategy Fallback Chain
```typescript
// lib/sepay.ts
export async function findOrderByTransaction(
payload: SepayWebhookPayload
): Promise<{ order: Order | null; matchMethod: string }> {
const { content, transferAmount, transactionDate } = payload;
// Strategy 1: Parse Order ID from content (preferred)
const parsedOrderId = parseOrderIdFromContent(content);
if (parsedOrderId) {
const order = await db.select()
.from(orders)
.where(eq(orders.id, parsedOrderId))
.limit(1);
if (order[0]) {
return { order: order[0], matchMethod: 'content-parse' };
}
}
// Strategy 2: Team payment ID match
const teamMatch = content.match(/TEAM([A-F0-9]{8})/i);
if (teamMatch) {
const teamPaymentId = `TEAM${teamMatch[1].toUpperCase()}`;
const order = await db.select()
.from(orders)
.where(eq(orders.paymentId, teamPaymentId))
.limit(1);
if (order[0]) {
return { order: order[0], matchMethod: 'team-payment-id' };
}
}
// Strategy 3: Amount + timestamp window (±30 minutes)
const transactionTime = new Date(transactionDate);
const windowStart = new Date(transactionTime.getTime() - 30 * 60 * 1000);
const windowEnd = new Date(transactionTime.getTime() + 30 * 60 * 1000);
const windowMatches = await db.select()
.from(orders)
.where(and(
eq(orders.status, 'pending'),
eq(orders.paymentProvider, 'sepay'),
eq(orders.amount, transferAmount),
gte(orders.createdAt, windowStart),
lte(orders.createdAt, windowEnd)
))
.limit(10);
if (windowMatches.length === 1) {
return { order: windowMatches[0], matchMethod: 'timestamp-window' };
}
if (windowMatches.length > 1) {
// Multiple matches - select closest by creation time
const closest = windowMatches.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.createdAt.getTime() - transactionTime.getTime());
const currDiff = Math.abs(curr.createdAt.getTime() - transactionTime.getTime());
return currDiff < prevDiff ? curr : prev;
});
return { order: closest, matchMethod: 'timestamp-window-closest' };
}
// Strategy 4: Amount only (last resort - single match only)
const amountMatches = await db.select()
.from(orders)
.where(and(
eq(orders.status, 'pending'),
eq(orders.paymentProvider, 'sepay'),
eq(orders.amount, transferAmount)
))
.limit(2);
if (amountMatches.length === 1) {
console.warn(`⚠️ Amount-only match for ${transferAmount} VND - verify manually`);
return { order: amountMatches[0], matchMethod: 'amount-only' };
}
// No match found
console.error(`❌ Could not match order:
Content: "${content}"
Amount: ${transferAmount} VND
Transaction Date: ${transactionDate}`);
return { order: null, matchMethod: 'none' };
}
```
### UUID Parsing with Bank Transformations
```typescript
// lib/sepay.ts
export function parseOrderIdFromContent(content: string): string | null {
if (!content) return null;
// Pattern 1: Standard "CLAUDEKIT {uuid}"
const claudekitMatch = content.match(/CLAUDEKIT\s+([\w-]+)/i);
if (claudekitMatch) {
return normalizeUUID(claudekitMatch[1]);
}
// Pattern 2: UUID anywhere in content (banks may strip/transform content)
// Match 8-4-4-4-12 hex with optional dashes
const uuidMatch = content.match(
/([0-9A-F]{8}-?[0-9A-F]{4}-?[0-9A-F]{4}-?[0-9A-F]{4}-?[0-9A-F]{12})/i
);
if (uuidMatch) {
return normalizeUUID(uuidMatch[1]);
}
return null;
}
function normalizeUUID(input: string): string | null {
// Remove dashes and validate
const cleaned = input.replace(/-/g, '');
if (cleaned.length !== 32) return null;
if (!/^[0-9a-f]+$/i.test(cleaned)) return null;
// Re-format to standard UUID format
return [
cleaned.slice(0, 8),
cleaned.slice(8, 12),
cleaned.slice(12, 16),
cleaned.slice(16, 20),
cleaned.slice(20),
].join('-').toLowerCase();
}
```
### Handled Content Formats
```
CLAUDEKIT 4e4635f4-0478-4080-a5c5-48da91f97f1e ✅ Standard
CLAUDEKIT 4e4635f404784080a5c548da91f97f1e ✅ Bank stripped dashes
CLAUDEKIT4e4635f404784080a5c548da91f97f1e ✅ No space
4e4635f404784080a5c548da91f97f1e-CLAUDEKIT ✅ Reversed
claudekit 4e4635f4-0478-4080-a5c5-48da91f97f1e ✅ Lowercase
BankAPINotify 4e4635f404784080a5c548da91f97f1e... ✅ Extra prefix
4e4635f404784080a5c548da91f97f1e ✅ UUID only
```
## Transaction Processing
### Complete Processing Flow
```typescript
async function processTransaction(payload: SepayWebhookPayload) {
// 1. Only process incoming transfers
if (payload.transferType !== 'in') {
console.log('Skipping outbound transfer');
return;
}
// 2. Find matching order
const { order, matchMethod } = await findOrderByTransaction(payload);
if (!order) {
console.error('No matching order found');
return;
}
// 3. Verify amount (allow overpayment)
if (payload.transferAmount < order.amount) {
console.error(`Underpayment: expected ${order.amount}, got ${payload.transferAmount}`);
return;
}
if (payload.transferAmount > order.amount) {
console.log(`Overpayment accepted: expected ${order.amount}, got ${payload.transferAmount}`);
}
// 4. Update order with transaction details
const existingMetadata = order.metadata ? JSON.parse(order.metadata) : {};
await db.update(orders)
.set({
status: 'completed',
paymentId: String(payload.id),
metadata: JSON.stringify({
...existingMetadata, // Preserve discount info
gateway: payload.gateway,
transactionDate: payload.transactionDate,
accountNumber: payload.accountNumber,
transferAmount: payload.transferAmount,
content: payload.content,
matchMethod,
transactionId: payload.id,
}),
updatedAt: new Date(),
})
.where(eq(orders.id, order.id));
// 5. Create license (non-blocking)
try {
await createLicense(order);
} catch (error) {
console.error('Failed to create license:', error);
}
// 6. Send confirmation email (non-blocking)
try {
await sendOrderConfirmation(order, payload);
} catch (error) {
console.error('Failed to send confirmation:', error);
}
// 7. Create referral commission (non-blocking)
if (order.referredBy) {
try {
// Commission based on actual paid amount
await createCommission({
orderId: order.id,
referrerId: order.referredBy,
baseAmount: payload.transferAmount, // Actual paid amount
currency: 'VND',
});
} catch (error) {
console.error('Failed to create commission:', error);
}
}
// 8. Update referrer tier (non-blocking)
if (order.referredBy) {
try {
const usdConversion = await convertVndToUsd(payload.transferAmount);
await updateReferrerTier(order.referredBy, usdConversion.usdCents, order.id);
} catch (error) {
console.error('Failed to update tier:', error);
}
}
// 9. Grant GitHub access (non-blocking)
try {
const metadata = JSON.parse(order.metadata || '{}');
await inviteToGitHub(metadata.githubUsername, order.productType);
} catch (error) {
console.error('Failed to invite to GitHub:', error);
}
// 10. Sync Polar discount redemption (non-blocking)
const metadata = JSON.parse(order.metadata || '{}');
if (metadata.couponId && metadata.couponCode) {
try {
await syncPolarDiscountWithRetry(order.id, metadata.couponId, metadata.couponCode);
} catch (error) {
console.error('Failed to sync Polar discount:', error);
await sendDiscordAlert('Polar discount sync failed', { orderId: order.id });
}
}
// 11. Send sales notification (non-blocking)
try {
await sendSalesNotification({
...order,
gateway: payload.gateway,
transactionId: payload.id,
});
} catch (error) {
console.error('Failed to send Discord notification:', error);
}
}
```
## Currency Conversion
### VND to USD with Multi-Layer Fallback
```typescript
// lib/currency.ts
const EXCHANGE_RATE_CACHE_TTL = 60 * 60 * 1000; // 1 hour
const FALLBACK_VND_TO_USD = 24500; // Conservative fallback
let exchangeRateCache: {
rate: number;
timestamp: number;
source: 'api' | 'cached' | 'expired' | 'fallback';
} | null = null;
export async function convertVndToUsd(vndAmount: number): Promise<{
usdCents: number;
rate: number;
source: string;
}> {
const now = Date.now();
// Layer 1: Fresh cache
if (exchangeRateCache && now - exchangeRateCache.timestamp < EXCHANGE_RATE_CACHE_TTL) {
const usdCents = Math.round((vndAmount / exchangeRateCache.rate) * 100);
return { usdCents, rate: exchangeRateCache.rate, source: 'cached' };
}
// Layer 2: Try live API
try {
const response = await fetch(
'https://api.exchangerate-api.com/v4/latest/USD',
{ signal: AbortSignal.timeout(5000) }
);
const data = await response.json();
const rate = data.rates.VND;
exchangeRateCache = { rate, timestamp: now, source: 'api' };
const usdCents = Math.round((vndAmount / rate) * 100);
return { usdCents, rate, source: 'api' };
} catch (error) {
console.warn('Exchange rate API failed:', error);
// Layer 3: Expired cache (better than nothing)
if (exchangeRateCache) {
const usdCents = Math.round((vndAmount / exchangeRateCache.rate) * 100);
return { usdCents, rate: exchangeRateCache.rate, source: 'expired_cache' };
}
// Layer 4: Hardcoded fallback
const usdCents = Math.round((vndAmount / FALLBACK_VND_TO_USD) * 100);
return { usdCents, rate: FALLBACK_VND_TO_USD, source: 'fallback' };
}
}
```
### USD Discount to VND
```typescript
// When Polar discount is in USD, convert to VND for SePay checkout
export function convertUsdDiscountToVnd(
discount: { type: 'fixed' | 'percentage'; amount?: number; basisPoints?: number },
amountVND: number
): number {
if (discount.type === 'percentage') {
// Basis points: 1000 = 10%, 10000 = 100%
const percentage = (discount.basisPoints || 0) / 10000;
return Math.round(amountVND * percentage);
} else {
// Fixed amount in USD cents → VND
const usdDollars = (discount.amount || 0) / 100;
return Math.round(usdDollars * 24500); // Use conservative rate
}
}
```
## Invoice Email Template
### HTML Invoice Generation
```typescript
// lib/emails/sepay-invoice.ts
export function generateSepayInvoice(order: Order, transaction: TransactionInfo): string {
const metadata = JSON.parse(order.metadata || '{}');
const invoiceNumber = `INV-${format(new Date(), 'yyyyMMdd')}-${order.id.slice(-8).toUpperCase()}`;
// Format VND with Vietnamese locale
const formatVND = (amount: number) =>
new Intl.NumberFormat('vi-VN', { style: 'currency', currency: 'VND' }).format(amount);
// Escape HTML to prevent XSS
const escapeHtml = (text: string) =>
text.replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[char] || char);
return `
<!DOCTYPE html>
<html>
<head>
<style>
.invoice { font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; }
.header { background: linear-gradient(135deg, #ff6b6b, #feca57); padding: 20px; }
.status { background: #10b981; color: white; padding: 4px 12px; border-radius: 4px; }
.amount { font-size: 24px; font-weight: bold; }
.savings { color: #10b981; }
</style>
</head>
<body>
<div class="invoice">
<div class="header">
<h1>Invoice</h1>
<span class="status">PAID</span>
</div>
<table>
<tr><td>Invoice #:</td><td>${invoiceNumber}</td></tr>
<tr><td>Customer:</td><td>${escapeHtml(metadata.name || order.email)}</td></tr>
<tr><td>Email:</td><td>${escapeHtml(order.email)}</td></tr>
<tr><td>Payment Date:</td><td>${format(new Date(transaction.transactionDate), 'dd/MM/yyyy HH:mm')}</td></tr>
<tr><td>Transaction Ref:</td><td>${transaction.transactionId || 'N/A'}</td></tr>
</table>
<h3>Order Details</h3>
<table>
<tr><td>Product:</td><td>${getProductName(order.productType)}</td></tr>
<tr><td>Original Price:</td><td>${formatVND(metadata.originalAmount || order.amount)}</td></tr>
${metadata.couponDiscountAmount ? `
<tr><td>Coupon (${metadata.couponCode}):</td><td>-${formatVND(metadata.couponDiscountAmount)}</td></tr>
` : ''}
${metadata.referralDiscountAmount ? `
<tr><td>Referral Discount (20%):</td><td>-${formatVND(metadata.referralDiscountAmount)}</td></tr>
` : ''}
${order.discountAmount > 0 ? `
<tr class="savings"><td>Total Savings:</td><td>-${formatVND(order.discountAmount)}</td></tr>
` : ''}
<tr class="amount"><td>Total Paid:</td><td>${formatVND(order.amount)}</td></tr>
</table>
<p>Thank you for your purchase!</p>
<p>Support: support@claudekit.com</p>
</div>
</body>
</html>
`;
}
```
## Error Handling Patterns
### Always Return 200 to SePay
```typescript
// Webhook must always return 200 to prevent retry loop
export async function POST(request: Request) {
try {
// ... processing
} catch (error) {
// Log error but don't fail
console.error('Webhook processing error:', error);
await logWebhookError(error);
}
// ALWAYS return 200
return NextResponse.json({ success: true });
}
```
### Non-Blocking Post-Payment Operations
```typescript
// Wrap each operation in try-catch
const operations = [
{ name: 'License', fn: () => createLicense(order) },
{ name: 'Email', fn: () => sendOrderConfirmation(order) },
{ name: 'Commission', fn: () => createCommission(order) },
{ name: 'GitHub', fn: () => inviteToGitHub(username, productType) },
{ name: 'Discord', fn: () => sendSalesNotification(order) },
];
for (const op of operations) {
try {
await op.fn();
console.log(`✅ ${op.name} completed`);
} catch (error) {
console.error(`❌ ${op.name} failed:`, error);
// Continue - don't block other operations
}
}
```
### Amount Validation
```typescript
// Reject underpayment, accept overpayment
if (transferAmount < order.amount) {
console.error(`Underpayment: expected ${order.amount}, received ${transferAmount}`);
await flagOrderForReview(order.id, 'underpayment');
return; // Don't process
}
if (transferAmount > order.amount) {
console.log(`Overpayment: expected ${order.amount}, received ${transferAmount}`);
// Continue processing - customer paid more than required
}
```
## Testing Patterns
### Unit Tests for UUID Parsing
```typescript
// __tests__/lib/sepay.test.ts
describe('parseOrderIdFromContent', () => {
it('parses standard format', () => {
expect(parseOrderIdFromContent('CLAUDEKIT 4e4635f4-0478-4080-a5c5-48da91f97f1e'))
.toBe('4e4635f4-0478-4080-a5c5-48da91f97f1e');
});
it('handles bank dash-stripping', () => {
expect(parseOrderIdFromContent('CLAUDEKIT 4e4635f404784080a5c548da91f97f1e'))
.toBe('4e4635f4-0478-4080-a5c5-48da91f97f1e');
});
it('handles real-world Vietnamese bank memo', () => {
expect(parseOrderIdFromContent('BankAPINotify 4e4635f404784080a5c548da91f97f1e-CHUYEN TIEN'))
.toBe('4e4635f4-0478-4080-a5c5-48da91f97f1e');
});
it('returns null for invalid content', () => {
expect(parseOrderIdFromContent('CLAUDEKIT')).toBeNull();
expect(parseOrderIdFromContent('4e4635f4-0478')).toBeNull();
expect(parseOrderIdFromContent('104588021672-CLAUDEKIT')).toBeNull();
});
});
```
### Webhook Integration Test Script
```bash
#!/bin/bash
# scripts/test-sepay-webhook.sh
BASE_URL="http://localhost:3000/api/webhooks/sepay"
API_KEY="your-test-key"
# Test 1: Valid Bearer token
echo "Test 1: Bearer token auth"
curl -X POST "$BASE_URL" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":12345,"content":"CLAUDEKIT test-uuid","transferAmount":2450000,"transferType":"in"}'
# Test 2: Valid Apikey format
echo "Test 2: Apikey auth"
curl -X POST "$BASE_URL" \
-H "Authorization: Apikey $API_KEY" \
-d '{"id":12346,"content":"CLAUDEKIT test-uuid","transferAmount":2450000,"transferType":"in"}'
# Test 3: Missing auth (should return 401)
echo "Test 3: No auth (expect 401)"
curl -X POST "$BASE_URL" \
-d '{"id":12347,"content":"test","transferAmount":100000,"transferType":"in"}'
# Test 4: Invalid key (should return 401)
echo "Test 4: Invalid key (expect 401)"
curl -X POST "$BASE_URL" \
-H "Authorization: Bearer wrong-key" \
-d '{"id":12348,"content":"test","transferAmount":100000,"transferType":"in"}'
```
## Database Schema
### Orders Table Extensions for SePay
```typescript
// Fields used specifically for SePay
{
paymentId: text('payment_id'), // Transaction content or TEAM{8} code
paymentProvider: literal('sepay'), // Distinguishes from Polar
currency: literal('VND'), // Always VND for SePay
amount: integer('amount'), // In VND (no decimals)
}
// Metadata JSON includes:
{
gateway: string, // Bank name from webhook
transactionDate: string, // Webhook timestamp
transactionId: number, // SePay transaction ID
transferAmount: number, // Actual received amount
matchMethod: string, // How order was matched
content: string, // Original transaction memo
encryptedTaxId?: string, // For VAT invoices
}
```
### Recommended Indexes
```sql
CREATE INDEX idx_orders_sepay_pending ON orders (status, payment_provider, amount)
WHERE status = 'pending' AND payment_provider = 'sepay';
CREATE INDEX idx_orders_sepay_timestamp ON orders (created_at)
WHERE payment_provider = 'sepay';
CREATE INDEX idx_orders_payment_id ON orders (payment_id)
WHERE payment_provider = 'sepay';
```
## Production Checklist
- [ ] Environment variables configured
- [ ] Bank account verified and active
- [ ] Webhook endpoint publicly accessible (HTTPS)
- [ ] Webhook API key set and verified
- [ ] Timing-safe auth comparison implemented
- [ ] Idempotency handling tested with duplicate webhooks
- [ ] UUID parsing tested with real Vietnamese bank memos
- [ ] Amount validation (underpayment rejection) tested
- [ ] Overpayment handling verified
- [ ] Currency conversion fallback chain tested
- [ ] Invoice email template tested
- [ ] Error monitoring enabled
- [ ] Structured logging in place
- [ ] Database indexes created
- [ ] Polar discount sync tested (for shared coupons)
- [ ] Team payment ID format tested
- [ ] Non-blocking operations wrapped in try-catch
- [ ] Always-200 webhook response verified
## Common Pitfalls
1. **Not handling bank dash-stripping** - Banks may remove dashes from UUIDs
2. **Rejecting overpayments** - Should accept; customer paid more
3. **Blocking webhook on non-critical failures** - Wrap in try-catch, continue
4. **Not using timing-safe comparison** - Vulnerable to timing attacks
5. **Returning non-200 on error** - Causes SePay retry loops
6. **Using raw exchange rates without fallback** - API can fail
7. **Applying discounts in wrong order** - Always coupon first, then referral
8. **Not logging matchMethod** - Hard to debug failed matches
9. **Not preserving checkout metadata** - Lose discount audit trail
10. **Synchronous Polar discount sync** - Can fail; use retry with backoff
11. **Case-sensitive content matching** - Banks may uppercase/lowercase
12. **Missing amount-only match safety** - Reject ambiguous matches

View File

@@ -0,0 +1,138 @@
# SePay Overview
Vietnamese payment automation platform serving as intermediary between applications and banks.
## Core Capabilities
**Payment Methods:**
- VietQR - QR code bank transfers (NAPAS standard)
- NAPAS QR - National payment gateway QR
- Bank Cards - Visa/Mastercard/JCB
- Bank Transfers - Direct bank-to-bank
- Virtual Accounts - Order-specific VAs with exact matching
**Supported Banks:** 44+ banks via NAPAS, 37+ with VietQR (Vietcombank, VPBank, BIDV, etc.)
**Use Cases:**
- Payment gateway for online payments
- Bank API direct connection
- Transaction verification automation
- Real-time balance monitoring
## Authentication
### API Token (Simple)
**Create:**
1. Company Configuration → API Access → "+ Add API"
2. Provide name, set status "Active"
3. Copy token from list
**Usage:**
```
Authorization: Bearer {API_TOKEN}
Content-Type: application/json
```
**Note:** All tokens have full access (no permission levels currently)
### OAuth2 (Advanced)
**Scopes:**
- `bank-account:read` - View accounts, balances
- `transaction:read` - Transaction history
- `webhook:read/write/delete` - Webhook management
- `profile` - User information
- `company` - Company details
**Authorization Code Flow:**
1. **Authorization Request:**
```
GET https://my.sepay.vn/oauth/authorize?
response_type=code&
client_id={CLIENT_ID}&
redirect_uri={REDIRECT_URI}&
scope={SCOPES}&
state={CSRF_TOKEN}
```
2. **Token Exchange (server-side only):**
```
POST https://my.sepay.vn/oauth/token
{
"grant_type": "authorization_code",
"client_id": "{CLIENT_ID}",
"client_secret": "{CLIENT_SECRET}",
"code": "{AUTHORIZATION_CODE}"
}
```
3. **Token Refresh:**
```
POST https://my.sepay.vn/oauth/token
{
"grant_type": "refresh_token",
"refresh_token": "{REFRESH_TOKEN}",
"client_id": "{CLIENT_ID}",
"client_secret": "{CLIENT_SECRET}"
}
```
**Security:** Access tokens expire ~1 hour, never expose client_secret, use state for CSRF protection
## Payment Gateway Flow (13 Steps)
1. Customer selects products, initiates payment
2. Merchant creates order record
3. Generate checkout form with HMAC-SHA256 signature
4. Send request to `/v1/checkout/init`
5. SePay validates signature
6. Redirect customer to SePay gateway
7. Customer selects payment method
8. SePay communicates with banks/card networks
9. Financial institution returns result
10. Callback notification sent to merchant
11. IPN (Instant Payment Notification) transmitted
12. Customer redirected to merchant result page
13. Final outcome displayed
## Environments
**Sandbox:**
- Dashboard: https://my.sepay.vn (free tier)
- Endpoint: https://sandbox.pay.sepay.vn/v1/init
- Credentials: `SP-TEST-XXXXXXX`, `spsk_test_xxxxxxxxxxxxx`
**Production:**
- Endpoint: https://pay.sepay.vn/v1/init
- Requirements: Personal/business bank account, completed testing
- Approval: 3-7 days for NAPAS QR/cards (requires documentation)
## Rate Limits
**Limit:** 2 calls/second
**Response:** HTTP 429 with `x-sepay-userapi-retry-after` header (seconds to wait)
**Handling:**
```javascript
if (response.status === 429) {
const retryAfter = response.headers.get('x-sepay-userapi-retry-after');
await sleep(retryAfter * 1000);
return retry();
}
```
## Support
- Email: info@sepay.vn
- Hotline: 02873059589 (24/7)
- Docs: https://developer.sepay.vn/en
- GitHub: https://github.com/sepayvn
## Next Steps
- **For API integration:** Load `api.md`
- **For SDK integration:** Load `sdk.md`
- **For webhook setup:** Load `webhooks.md`
- **For QR generation:** Load `qr-codes.md`

View File

@@ -0,0 +1,228 @@
# SePay VietQR Generation
Dynamic QR code generation service compatible with VietQR standard (NAPAS).
## API Endpoint
```
https://qr.sepay.vn/img?acc={ACCOUNT}&bank={BANK}&amount={AMOUNT}&des={DESCRIPTION}
```
## Parameters
**Required:**
- `acc` - Bank account number
- `bank` - Bank code or short name
**Optional:**
- `amount` - Transfer amount (omit for flexible amount)
- `des` - Transfer description/content (URL encoded)
- `template` - QR image template (empty/compact/qronly)
- `download` - Set to "true" to download image
## Examples
### Complete QR (Fixed Amount)
```
https://qr.sepay.vn/img?
acc=0010000000355&
bank=Vietcombank&
amount=100000&
des=ung%20ho%20quy%20bao%20tro%20tre%20em
```
### Flexible QR (Customer Enters Amount)
```
https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank
```
### QR Only Template
```
https://qr.sepay.vn/img?
acc=0010000000355&
bank=Vietcombank&
amount=100000&
template=qronly
```
## Integration
### HTML
```html
<img src="https://qr.sepay.vn/img?acc=0010000000355&bank=Vietcombank&amount=100000"
alt="Payment QR Code" />
```
### JavaScript (Dynamic)
```javascript
function generatePaymentQR(account, bank, amount, description) {
const params = new URLSearchParams({
acc: account,
bank: bank,
amount: amount,
des: description
});
return `https://qr.sepay.vn/img?${params}`;
}
// Usage
const qrUrl = generatePaymentQR(
'0010000000355',
'Vietcombank',
100000,
'Order #12345'
);
document.getElementById('qr-code').src = qrUrl;
```
### PHP (Dynamic)
```php
<?php
function generatePaymentQR($account, $bank, $amount, $description) {
return 'https://qr.sepay.vn/img?' . http_build_query([
'acc' => $account,
'bank' => $bank,
'amount' => $amount,
'des' => $description
]);
}
// Usage
$qrUrl = generatePaymentQR(
'0010000000355',
'Vietcombank',
100000,
'Order #' . $orderId
);
echo "<img src='{$qrUrl}' alt='Payment QR' />";
?>
```
### Node.js (Express)
```javascript
app.get('/payment/:orderId/qr', async (req, res) => {
const order = await Order.findById(req.params.orderId);
const qrUrl = new URL('https://qr.sepay.vn/img');
qrUrl.searchParams.set('acc', process.env.SEPAY_ACCOUNT);
qrUrl.searchParams.set('bank', process.env.SEPAY_BANK);
qrUrl.searchParams.set('amount', order.total);
qrUrl.searchParams.set('des', `Order ${order.id}`);
res.render('payment', { qrUrl: qrUrl.toString() });
});
```
### React Component
```jsx
function PaymentQR({ account, bank, amount, description }) {
const qrUrl = useMemo(() => {
const params = new URLSearchParams({
acc: account,
bank: bank,
amount: amount,
des: description
});
return `https://qr.sepay.vn/img?${params}`;
}, [account, bank, amount, description]);
return (
<div className="payment-qr">
<img src={qrUrl} alt="Payment QR Code" />
<p>Scan to pay {amount.toLocaleString('vi-VN')} VND</p>
</div>
);
}
```
## Templates
**Default:**
- Full QR with bank logo
- Account information displayed
- Branded with bank colors
**Compact:**
- Smaller version
- Minimal branding
- More space-efficient
**QR Only:**
- Pure QR code
- No decorations
- For custom layouts
## Bank Codes
**Get Bank List:**
```
GET https://qr.sepay.vn/banks.json
```
**Common Banks:**
- Vietcombank (VCB)
- VPBank
- BIDV
- Techcombank (TCB)
- ACB
- MB Bank
- Sacombank
- VietinBank
- And 40+ others
**Cache Bank List:**
```javascript
// Fetch once and cache
const banks = await fetch('https://qr.sepay.vn/banks.json')
.then(res => res.json());
// Store in memory or Redis
cache.set('sepay_banks', banks, 86400); // 24 hours
```
## Best Practices
1. **Cache Bank List:** Avoid repeated API calls
2. **URL Encode Descriptions:** Use `encodeURIComponent()` or `http_build_query()`
3. **Error Handling:** Provide fallback for QR generation failures
4. **Amount Validation:** Ensure amount is positive integer
5. **Flexible vs Fixed:** Use flexible QR for varying amounts
6. **Template Selection:** Choose based on UI design
7. **Responsive Design:** Scale QR code for mobile devices
8. **Alt Text:** Always provide descriptive alt text
9. **Loading State:** Show placeholder while QR loads
10. **Print Support:** Ensure QR codes are print-friendly
## Integration Patterns
### Checkout Page
```html
<div class="payment-methods">
<h3>Pay via Bank Transfer</h3>
<img src="[QR_URL]" alt="Payment QR Code" class="qr-code" />
<p>Scan this QR code with your banking app</p>
<div class="payment-details">
<p><strong>Account:</strong> 0010000000355</p>
<p><strong>Bank:</strong> Vietcombank</p>
<p><strong>Amount:</strong> 100,000 VND</p>
<p><strong>Content:</strong> Order #12345</p>
</div>
</div>
```
### Email Receipt
```html
<table>
<tr>
<td align="center">
<img src="[QR_URL]" alt="Payment QR Code" width="200" />
<p>Scan to pay for your order</p>
</td>
</tr>
</table>
```
### PDF Invoice
Use QR URL in PDF generation libraries (wkhtmltopdf, Puppeteer, etc.)

View File

@@ -0,0 +1,213 @@
# SePay SDK Integration
Official SDKs for Node.js, PHP, and Laravel.
## Node.js SDK (sepay-pg-node)
**Installation:**
```bash
npm install github:sepay/sepay-pg-node
```
**Requirements:** Node.js 16+
**Configuration:**
```javascript
import { SePayPgClient } from 'sepay-pg-node';
const client = new SePayPgClient({
env: 'sandbox', // or 'production'
merchant_id: 'SP-TEST-XXXXXXX',
secret_key: 'spsk_test_xxxxxxxxxxxxx',
});
```
**Create Payment:**
```javascript
const fields = client.checkout.initOneTimePaymentFields({
operation: 'PURCHASE',
order_invoice_number: 'DH0001',
order_amount: 10000,
currency: 'VND',
success_url: 'https://example.com/success',
error_url: 'https://example.com/error',
cancel_url: 'https://example.com/cancel',
order_description: 'Payment for order DH0001',
});
```
**Render Payment Form:**
```jsx
<form action={client.checkout.initCheckoutUrl()} method="POST">
{Object.keys(fields).map(field =>
<input type="hidden" name={field} value={fields[field]} key={field} />
)}
<button type="submit">Pay Now</button>
</form>
```
**API Methods:**
```javascript
// List all orders
await client.order.all({
per_page: 50,
q: 'search_term',
order_status: 'completed',
from_created_at: '2025-01-01',
to_created_at: '2025-01-31'
});
// Get order details
await client.order.retrieve('DH0001');
// Void transaction (cards only)
await client.order.voidTransaction('DH0001');
// Cancel order (QR payments)
await client.order.cancel('DH0001');
```
**Endpoints:**
- Sandbox: `https://sandbox.pay.sepay.vn/v1/init`
- Production: `https://pay.sepay.vn/v1/init`
## PHP SDK (sepay/sepay-pg)
**Installation:**
```bash
composer require sepay/sepay-pg
```
**Requirements:** PHP 7.4+, ext-json, ext-curl, Guzzle
**Quick Start:**
```php
use SePay\SePayClient;
use SePay\Builders\CheckoutBuilder;
$sepay = new SePayClient(
'SP-TEST-XXXXXXX',
'spsk_live_xxxxxxxxxxxxx',
SePayClient::ENVIRONMENT_SANDBOX
);
$checkoutData = CheckoutBuilder::make()
->currency('VND')
->orderAmount(100000)
->operation('PURCHASE')
->orderDescription('Test payment')
->orderInvoiceNumber('INV_001')
->successUrl('https://yoursite.com/success')
->errorUrl('https://yoursite.com/error')
->cancelUrl('https://yoursite.com/cancel')
->build();
echo $sepay->checkout()->generateFormHtml($checkoutData);
```
**Error Handling:**
```php
try {
$order = $sepay->orders()->retrieve('INV_001');
} catch (AuthenticationException $e) {
// Invalid credentials
} catch (ValidationException $e) {
// Invalid request data
$errors = $e->getErrors();
} catch (NotFoundException $e) {
// Resource not found
} catch (RateLimitException $e) {
// Rate limit exceeded
$retryAfter = $e->getRetryAfter();
} catch (ServerException $e) {
// Server error (5xx)
}
```
**Configuration:**
```php
$sepay->setConfig([
'timeout' => 30,
'retry_attempts' => 3,
'retry_delay' => 1000,
'debug' => true,
'user_agent' => 'MyApp/1.0',
'logger' => $psrLogger
]);
```
## Laravel Package (laravel-sepay)
**Installation:**
```bash
composer require sepayvn/laravel-sepay
# For Laravel 7-8 with PHP 7.4+
composer require "sepayvn/laravel-sepay:dev-lite"
```
**Setup:**
```bash
php artisan vendor:publish --tag="sepay-migrations"
php artisan migrate
php artisan vendor:publish --tag="sepay-config"
php artisan vendor:publish --tag="sepay-views" # optional
```
**Configuration (.env):**
```
SEPAY_WEBHOOK_TOKEN=your_secret_key
SEPAY_MATCH_PATTERN=SE
```
**Create Event Listener:**
```bash
php artisan make:listener SePayWebhookListener
```
**Listener Implementation:**
```php
<?php
namespace App\Listeners;
use SePayWebhookEvent;
class SePayWebhookListener
{
public function handle(SePayWebhookEvent $event)
{
$transaction = $event->transaction;
if ($transaction->transfer_type === 'in') {
// Handle incoming payment
Order::where('code', $transaction->content)
->update(['status' => 'paid']);
// Send confirmation email
Mail::to($order->customer->email)
->send(new PaymentConfirmation($order));
}
}
}
```
**Register Listener:**
```php
// app/Providers/EventServiceProvider.php
protected $listen = [
SePayWebhookEvent::class => [
SePayWebhookListener::class,
],
];
```
## Best Practices
1. **Environment Variables:** Store credentials securely
2. **Error Handling:** Catch and log all exceptions
3. **Retry Logic:** Implement for transient failures
4. **Logging:** Log all API calls and responses
5. **Testing:** Use sandbox extensively before production
6. **Validation:** Validate data before API calls
7. **Monitoring:** Track success/failure rates

View File

@@ -0,0 +1,208 @@
# SePay Webhooks
Real-time payment notifications from SePay to your server.
## Setup
1. Access WebHooks menu in dashboard
2. Click "+ Add webhooks"
3. Configure:
- **Name:** Descriptive identifier
- **Event Selection:** `All`, `In_only`, `Out_only`
- **Conditions:** Bank accounts, VA filtering, payment code requirements
- **Webhook URL:** Your callback endpoint (must be publicly accessible)
- **Is Verify Payment:** Flag for validation
- **Authentication:** `No_Authen`, `OAuth2.0`, or `Api_Key`
4. Click "Add" to finalize
## Payload Structure
```json
{
"id": 92704,
"gateway": "Vietcombank",
"transactionDate": "2023-03-25 14:02:37",
"accountNumber": "0123499999",
"code": null,
"content": "payment content",
"transferType": "in",
"transferAmount": 2277000,
"accumulated": 19077000,
"subAccount": null,
"referenceCode": "MBVCB.3278907687"
}
```
**Fields:**
- `id` - Unique transaction ID (use for deduplication)
- `gateway` - Bank name
- `transactionDate` - Transaction timestamp
- `accountNumber` - Bank account number
- `code` - Payment code (if available)
- `content` - Transfer description/content
- `transferType` - "in" (incoming) or "out" (outgoing)
- `transferAmount` - Transaction amount
- `accumulated` - Account balance after transaction
- `subAccount` - Sub-account identifier
- `referenceCode` - Bank transaction reference
## Authentication
**API Key:**
```
Authorization: Apikey YOUR_KEY
Content-Type: application/json
```
**OAuth 2.0:**
Provide token endpoint, client ID, and client secret in dashboard.
**No Authentication:**
Available but not recommended for production. Consider IP whitelisting.
## Response Requirements
**Success Response:**
```json
HTTP/1.1 200 OK
{
"success": true
}
```
**Accepted:** Any 2xx status code (200-201)
**Timeout:** Respond within 5 seconds
## Auto-Retry Mechanism
**Policy:**
- Retries up to 7 times over ~5 hours
- Fibonacci sequence intervals (1, 1, 2, 3, 5, 8, 13... minutes)
**Duplicate Prevention:**
```javascript
// Primary: Use transaction ID
const exists = await db.transactions.findOne({ sepay_id: data.id });
if (exists) return { success: true };
// Alternative: Composite key
const key = `${data.referenceCode}-${data.transferType}-${data.transferAmount}`;
```
## Implementation Examples
### Node.js/Express
```javascript
app.post('/webhook/sepay', async (req, res) => {
const transaction = req.body;
// Check duplicates
if (await isDuplicate(transaction.id)) {
return res.json({ success: true });
}
// Process transaction
if (transaction.transferType === 'in') {
await processPayment({
amount: transaction.transferAmount,
content: transaction.content,
referenceCode: transaction.referenceCode
});
}
// Save to database
await db.transactions.insert(transaction);
res.json({ success: true });
});
```
### PHP
```php
<?php
$data = json_decode(file_get_contents('php://input'), true);
// Check duplicates
$exists = $db->query("SELECT id FROM transactions WHERE sepay_id = ?", [$data['id']]);
if ($exists) {
echo json_encode(['success' => true]);
exit;
}
// Process payment
if ($data['transferType'] == 'in') {
processPayment($data['transferAmount'], $data['content']);
}
// Save to database
$db->insert('transactions', [
'sepay_id' => $data['id'],
'amount' => $data['transferAmount'],
'content' => $data['content'],
'reference_code' => $data['referenceCode']
]);
header('Content-Type: application/json');
echo json_encode(['success' => true]);
```
## Security Best Practices
1. **IP Whitelisting:** Restrict endpoint to SePay IPs
2. **API Key Verification:** Validate authorization header
3. **HTTPS Only:** Use SSL/TLS
4. **Duplicate Detection:** Prevent double processing
5. **Logging:** Maintain webhook logs
6. **Timeout Handling:** Respond quickly (<5s)
7. **Idempotency:** Same webhook multiple times = same result
## Monitoring
**Dashboard Features:**
- View webhook attempts
- Check response status
- Review retry history
- Manual retry option
**Application Monitoring:**
- Log all webhook receipts
- Track processing time
- Alert on failures
- Monitor duplicate rate
## OAuth2 Webhook Management API
**Available Scopes:** `webhook:read`, `webhook:write`, `webhook:delete`
**List Webhooks:**
```
GET /api/v1/webhooks
```
**Get Details:**
```
GET /api/v1/webhooks/{id}
```
**Create:**
```
POST /api/v1/webhooks
{
"bank_account_id": 123,
"name": "My Webhook",
"event_type": "All",
"authen_type": "Api_Key",
"webhook_url": "https://example.com/webhook",
"is_verify_payment": true
}
```
**Update:**
```
PATCH /api/v1/webhooks/{id}
```
**Delete:**
```
DELETE /api/v1/webhooks/{id}
```