Taiwan Payments for Indie Devs: ECPay Integration Guide

2026-02-26 · Jazz Lien

Why ECPay Over Stripe?

Stripe works in Taiwan, but consumers don't use it. ECPay (綠界) is what Taiwanese expect:

If you're selling to Taiwanese customers, ECPay removes friction.

Registration

As Individual (個人)

Start here. Requirements:

Limitations:

As Business (行號/公司)

Upgrade when revenue hits NT$240K/year (tax threshold). Requirements:

Benefits:

API Integration

ECPay uses form-encoded POST requests. No SDK required — good news for Edge Runtime.

Creating a Payment

const params = {
  MerchantID: process.env.ECPAY_MERCHANT_ID,
  MerchantTradeNo: `ORDER${Date.now()}`, // Unique per transaction
  MerchantTradeDate: formatDate(new Date()), // yyyy/MM/dd HH:mm:ss
  PaymentType: 'aio',
  TotalAmount: 299,
  TradeDesc: 'Glimmer Credits',
  ItemName: '1 Credit Pack',
  ReturnURL: 'https://yoursite.com/api/ecpay/webhook',
  ClientBackURL: 'https://yoursite.com/payment/complete',
  ChoosePayment: 'ALL', // Let user choose
  EncryptType: 1,
};

// Add CheckMacValue
params.CheckMacValue = generateCheckMac(params);

// Redirect user to ECPay
const form = new URLSearchParams(params);
// Return HTML form that auto-submits to ECPay

CheckMacValue Generation

ECPay verifies requests with SHA256 hash:

function generateCheckMac(params: Record<string, string>): string {
  // 1. Sort keys alphabetically
  const sorted = Object.keys(params)
    .sort()
    .map(k => `${k}=${params[k]}`)
    .join('&');

  // 2. Wrap with HashKey and HashIV
  const raw = `HashKey=${HASH_KEY}&${sorted}&HashIV=${HASH_IV}`;

  // 3. URL encode
  const encoded = encodeURIComponent(raw).toLowerCase();

  // 4. SHA256 hash, uppercase
  return sha256(encoded).toUpperCase();
}

Webhook Verification (Edge-Compatible)

ECPay POSTs payment results to your webhook. Verify before trusting:

export async function POST(request: Request) {
  const formData = await request.formData();
  const params = Object.fromEntries(formData);

  // Extract and remove CheckMacValue
  const receivedMac = params.CheckMacValue;
  delete params.CheckMacValue;

  // Regenerate and compare
  const expectedMac = generateCheckMac(params);

  if (receivedMac !== expectedMac) {
    return new Response('0|InvalidCheckMac', { status: 400 });
  }

  // Process payment
  if (params.RtnCode === '1') {
    // Payment successful
    await addCredits(params.CustomField1, parseInt(params.CustomField2));
  }

  // ECPay expects "1|OK" response
  return new Response('1|OK');
}

Edge Runtime SHA256

No crypto.createHash on Edge. Use Web Crypto:

async function sha256(message: string): Promise<string> {
  const msgBuffer = new TextEncoder().encode(message);
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

Testing

ECPay provides a staging environment:

Use staging for development. Switch to production credentials only after testing.

Test credit card:

Common Gotchas

1. MerchantTradeNo Must Be Unique

Each transaction needs a unique ID. Use timestamp + random:

const tradeNo = `${Date.now()}${Math.random().toString(36).slice(2, 6)}`;

2. Date Format Is Specific

ECPay wants yyyy/MM/dd HH:mm:ss in Taiwan timezone:

function formatECPayDate(date: Date): string {
  return date.toLocaleString('sv-SE', { timeZone: 'Asia/Taipei' })
    .replace(/-/g, '/')
    .replace('T', ' ');
}

3. ItemName Character Limit

Max 400 characters. Truncate if needed.

4. Webhook Retries

ECPay retries webhooks if you don't respond 1|OK. Make webhook handlers idempotent — check if payment already processed before adding credits.

Production Checklist


ECPay documentation (Chinese): https://www.ecpay.com.tw/Service/API_Dwnld