company_pay 台灣金流商工程實作比較
文件入口:company-pay.md
配套文件:
對象:後端工程師、技術主管。專注「實作會遇到什麼」而不是「賣不賣」。 最後更新:2026-06-22
1. 工程視角 TL;DR
| 維度 | 綠界 | 藍新 | 紅陽 | TapPay | Stripe |
|---|---|---|---|---|---|
| API 風格 | 表單跳轉 + 後端驗簽 | 同綠界 | 同綠界 | RESTful JSON | RESTful JSON |
| 認證方式 | MD5 / SHA256 簽章 | AES-256 + SHA256 | MD5 簽章 | API Key | Secret Key |
| Webhook 風格 | URL-encoded form POST | URL-encoded form POST | URL-encoded form POST | JSON POST | JSON POST |
| Webhook 簽章驗證 | CheckMacValue 自驗 | AES 解密自驗 | CheckSum 自驗 | HMAC SHA256 | HMAC SHA256(內建 SDK 驗) |
| SDK 品質 | PHP / Node 還行 | PHP 較完整、Node 老舊 | 幾乎沒 SDK | JS / iOS / Android 完整 | 全平台一流 |
| 文件結構 | PDF + 線上版混雜 | 同綠界 | 大量 PDF | 對標 Stripe,現代 | 業界標竿 |
| Sandbox | ✅ 完整 | ✅ 完整 | ✅ 有 | ✅ 完整 | ✅ 完整 |
| 預估串接工時 | 5-8 工作日 | 5-10 工作日 | 10-15 工作日 | 3-5 工作日 | 3-5 工作日 |
| 文件踩雷率 | 中 | 中高 | 高 | 低 | 極低 |
2. API 互動模型差異(這是最大鴻溝)
2.1 傳統台灣金流(綠界 / 藍新 / 紅陽)
「表單跳轉派」:
1. 你的後端產生一張隱藏表單 + 簽章
2. 前端 auto-submit 表單到金流商 hosted page
3. 使用者在 hosted page 完成付款
4. 金流商 server-to-server POST 回你的 callback URL(webhook)
5. 同時也會把使用者瀏覽器導回你的 return URL
6. 你要驗 webhook 簽章、回應 "1|OK"(綠界)或 "OK"(藍新)
特徵:
- 沒有 JSON request/response,全部是
application/x-www-form-urlencoded - 簽章演算法要自己實作(綠界 CheckMacValue、藍新 AES + SHA256)
- 回應內容要符合特定格式(綠界
1|OK、藍新SUCCESS),否則重試 - 「return URL」與「callback URL」是兩個不同概念,初學者最容易混淆
2.2 現代派(TapPay / Stripe)
「JSON API 派」:
1. 前端用 SDK 把卡號送到金流商(拿到 token / payment_method)
2. 前端拿 token 呼叫你的後端
3. 你的後端用 token + Secret Key 呼叫金流商 REST API
4. 金流商回 JSON 結果
5. 後續事件透過 webhook(JSON)通知
特徵:
- 標準 RESTful,回 JSON
- 簽章 SDK 內建處理
- Webhook 用 HMAC,SDK 直接驗
- 開發體驗跟一般 SaaS API 一樣
2.3 為什麼這條差很多
| 影響 | 表單跳轉派 | JSON API 派 |
|---|---|---|
| Mock 測試 | 痛苦(要 mock form post) | 簡單(mock fetch) |
| 自動化測試 | 需要 headless browser | 直接 unit test |
| 錯誤訊息 | 中文混英文、不一致 | 結構化 error code |
| 工程文化適配 | 接得到但工程師會抱怨 | 工程師覺得正常 |
3. 訂閱建立流程對比(程式碼層級)
3.1 綠界 ECPay 定期定額
// 後端產生訂單 + 簽章
import crypto from 'crypto'
const params = {
MerchantID: '2000132',
MerchantTradeNo: 'NO' + Date.now(),
MerchantTradeDate: '2026/06/22 12:00:00',
PaymentType: 'aio',
TotalAmount: 3000,
TradeDesc: '橋外生 Basic 月費',
ItemName: 'Basic Plan',
ReturnURL: 'https://wport.me/api/payment/ecpay/webhook',
ClientBackURL: 'https://wport.me/recruitment/billing/success',
ChoosePayment: 'Credit',
// 定期定額參數
PeriodAmount: 3000,
PeriodType: 'M', // M=月, D=日, Y=年
Frequency: 1, // 每 N 個 PeriodType 扣一次
ExecTimes: 99, // 最多扣幾次
PeriodReturnURL: 'https://wport.me/api/payment/ecpay/period-webhook',
}
// 1. 參數按 key 字母排序
// 2. 串成 a=1&b=2&...
// 3. 前後加 HashKey、HashIV
// 4. URL encode + 轉小寫
// 5. SHA256 大寫
const hashKey = process.env.ECPAY_HASH_KEY
const hashIV = process.env.ECPAY_HASH_IV
const sorted = Object.keys(params).sort().map(k => `${k}=${params[k]}`).join('&')
const raw = `HashKey=${hashKey}&${sorted}&HashIV=${hashIV}`
const encoded = encodeURIComponent(raw).toLowerCase()
.replace(/%20/g, '+') // 綠界規範要 + 而不是 %20
.replace(/%2d/g, '-')
.replace(/%5f/g, '_')
// ... 還有一串特殊字元處理
const checkMacValue = crypto.createHash('sha256').update(encoded).digest('hex').toUpperCase()
// 然後吐一個 HTML form 給前端 auto-submit
踩雷點:
MerchantTradeDate格式是YYYY/MM/DD HH:mm:ss,斜線不是橫線- 簽章前的 URL encode 有一堆例外字元(+, -, _, ., !, *, (, ))
- 金額不能有小數點
MerchantTradeNo不能超過 20 字、且不可重複(even after refund)
3.2 藍新 NewebPay 定期定額
import crypto from 'crypto'
const data = {
MerOrderNo: 'NO' + Date.now(),
ProdDesc: '橋外生 Basic 月費',
PeriodAmt: 3000,
PeriodType: 'M',
PeriodPoint: '15', // 每月 15 日扣
PeriodTimes: 99,
PayerEmail: 'user@example.com',
// ... 還有十幾個欄位
}
// 1. 轉成 query string
const queryString = new URLSearchParams(data).toString()
// 2. AES-256-CBC 加密
const hashKey = process.env.NEWEBPAY_HASH_KEY // 32 字元
const hashIV = process.env.NEWEBPAY_HASH_IV // 16 字元
const cipher = crypto.createCipheriv('aes-256-cbc', hashKey, hashIV)
let encrypted = cipher.update(queryString, 'utf8', 'hex')
encrypted += cipher.final('hex')
// 3. SHA256 簽章
const shaRaw = `HashKey=${hashKey}&${encrypted}&HashIV=${hashIV}`
const tradeSha = crypto.createHash('sha256').update(shaRaw).digest('hex').toUpperCase()
// 4. 吐 form 給前端 submit
const formData = {
MerchantID_: 'MS123456',
PostData_: encrypted,
TradeSha: tradeSha,
}
踩雷點:
- AES key / IV 是「字元數」不是 byte,要剛好 32 / 16
PeriodPoint規則複雜(月扣傳日期、週扣傳星期、日扣傳天數)- Webhook 回傳的資料也是 AES 加密,要解密才能讀
- 部分欄位沒填會被當成空字串送出,導致簽章對不上
3.3 TapPay 訂閱
// 前端拿卡號
const result = await TPDirect.card.getPrime()
const prime = result.card.prime
// 後端用 prime 建立 Card Token
const tokenRes = await fetch('https://sandbox.tappaysdk.com/tpc/card/bind', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.TAPPAY_PARTNER_KEY,
},
body: JSON.stringify({
prime,
merchant_id: 'YOUR_MERCHANT_ID',
currency: 'TWD',
cardholder: { name, email, phone_number },
}),
})
const { card_token } = await tokenRes.json()
// 自己用 cron 排程,每月用 card_token 扣款
await fetch('https://sandbox.tappaysdk.com/tpc/payment/pay-by-token', {
method: 'POST',
headers: { 'x-api-key': process.env.TAPPAY_PARTNER_KEY },
body: JSON.stringify({
card_key: card_token,
amount: 3000,
currency: 'TWD',
order_number: 'SUB-' + Date.now(),
}),
})
特徵:
- 標準 REST,每一步都是 JSON
- 訂閱排程要你自己寫(cron / queue)
- 失敗重試、續扣失敗通知、cancellation 都要自己實作
3.4 Stripe 訂閱
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
// 建立 Checkout Session
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: 'price_xxx', quantity: 1 }],
success_url: 'https://wport.me/billing/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'https://wport.me/billing/cancel',
customer_email: 'user@example.com',
})
// 把 session.url 回傳給前端,前端跳轉就好
return { checkout_url: session.url }
特徵:
- 5 行 code 搞定
- 續扣、proration、trial、Smart Retries、稅率、發票全部自動
- 文件、SDK、社群一流
4. Webhook 處理對比
4.1 綠界 Webhook(重點:回應格式)
app.post('/api/payment/ecpay/webhook', async (req, res) => {
const params = req.body // x-www-form-urlencoded
const receivedMac = params.CheckMacValue
delete params.CheckMacValue
// 自己重算 CheckMacValue
const calculatedMac = calculateCheckMacValue(params)
if (calculatedMac !== receivedMac) {
return res.status(400).send('0|InvalidCheckMacValue')
}
if (params.RtnCode === '1') {
// 付款成功
await markOrderPaid(params.MerchantTradeNo)
}
// ⚠️ 重要:必須回 "1|OK",不是 200 OK
res.send('1|OK')
})
踩雷點:
- 回應必須是
1|OK否則綠界會一直重送(24 小時內 10+ 次) - 重送機制冪等性要自己處理
- 定期定額的續扣 webhook 跟首次付款 webhook 是不同 URL 不同欄位
4.2 藍新 Webhook(重點:解密)
app.post('/api/payment/newebpay/webhook', async (req, res) => {
const { TradeInfo, TradeSha } = req.body
// 1. 驗 sha
const expectedSha = crypto.createHash('sha256')
.update(`HashKey=${hashKey}&${TradeInfo}&HashIV=${hashIV}`)
.digest('hex').toUpperCase()
if (expectedSha !== TradeSha) return res.status(400).send()
// 2. AES 解密
const decipher = crypto.createDecipheriv('aes-256-cbc', hashKey, hashIV)
let decrypted = decipher.update(TradeInfo, 'hex', 'utf8')
decrypted += decipher.final('utf8')
const data = JSON.parse(decrypted)
if (data.Status === 'SUCCESS') {
await markOrderPaid(data.Result.MerchantOrderNo)
}
res.send('SUCCESS') // 跟綠界回應內容不同
})
踩雷點:
- 解密失敗時通常是 padding 問題,要注意 PKCS7
- 定期定額的 webhook payload 結構跟首次付款不同
- 回應內容必須是
SUCCESS,不是 HTTP 200
4.3 Stripe Webhook(標準作法)
app.post('/api/payment/stripe/webhook',
express.raw({ type: 'application/json' }), // ⚠️ 必須 raw body
async (req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(
req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
)
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`)
}
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object)
break
case 'invoice.payment_succeeded':
await handleInvoicePaid(event.data.object)
break
// ...
}
res.json({ received: true })
}
)
特徵:
- SDK 內建 HMAC 驗證
- 回應只要 200 即可
- Stripe Dashboard 可重送、可看每次發送歷史
5. 各業者常見踩雷點
5.1 綠界
| 雷點 | 解法 |
|---|---|
| CheckMacValue 算不對 | URL encode 規則跟標準 RFC 不同,要照官方 PDF 寫 |
MerchantTradeNo 重複 | 即使訂單作廢也不能重用,前面加日期前綴 |
| 金額有小數點被拒 | 一律用整數 TWD |
| 測試環境跟正式環境 HashKey 不同 | 環境變數分開、Code Review 強檢 |
| 定期定額第一次扣款的 webhook 跟後續續扣不同 URL | 兩個都要實作 |
| 退款不能用 API,要登入後台手動 | SOP 設計時要考慮 |
5.2 藍新
| 雷點 | 解法 |
|---|---|
| AES key / IV 長度錯 | 必須剛好 32 / 16 字元(不是 byte) |
| Webhook 解密失敗 | 通常是 raw body 被 middleware 改過,要先存原始 body |
PeriodPoint 規則複雜 | 月扣 = 日(1-28)、週扣 = 星期、日扣 = 天數差 |
| 文件 PDF 跟線上版內容對不上 | 以線上版為準,PDF 是舊的 |
| Sandbox 跟正式環境 API endpoint 不同 | 環境變數分開 |
5.3 紅陽
| 雷點 | 解法 |
|---|---|
| 文件很多在 PDF 附件 | 開戶時請業務寄完整文件包 |
| API 命名跟其他家差異大 | 抽象層必做,不然耦合死 |
| 沒有官方 Node.js SDK | 自己包,或找社群版(品質不保證) |
| 客服反應慢 | 重要案件直接打電話 |
5.4 TapPay
| 雷點 | 解法 |
|---|---|
| 訂閱排程要自己寫 | 用 BullMQ / cron,注意重試與冪等 |
| Card Token 過期處理 | TapPay 提供卡片更新通知,需訂閱 |
| 3DS 流程要前端配合 | 用官方 JS SDK |
5.5 Stripe
| 雷點 | 解法 |
|---|---|
| Webhook raw body 處理 | Express 要用 express.raw() 而非 express.json() |
| 海外卡 3DS 強制 | 用 PaymentIntent 而非舊版 Charge API |
| 台灣帳號限制 | Stripe Taiwan 部分功能(Connect Express)尚未支援 |
| 撥款週期 7 天起 | 新帳號可能更久,要先跟 Stripe 確認 |
| Webhook 順序不保證 | 用 idempotency key 與 status 機判斷 |
6. 工時預估(單一金流,不含雙軌)
以「完整訂閱流程 + 自定義收款 + 雙發票」為例:
| 項目 | 綠界 | 藍新 | 紅陽 | TapPay | Stripe |
|---|---|---|---|---|---|
| 環境設定 / 申請帳號 | 1 天 | 1 天 | 1 天 | 1 天 | 1 天 |
| 訂閱建立流程 | 1.5 天 | 1.5 天 | 3 天 | 1 天 | 0.5 天 |
| Webhook 接收 + 驗簽 | 1 天 | 1.5 天 | 2 天 | 0.5 天 | 0.5 天 |
| 一次性收款(admin 自定義) | 1 天 | 1 天 | 1.5 天 | 0.5 天 | 0.5 天 |
| 訂閱續扣排程 / 失敗重試 | 內建 | 內建 | 內建 | 3 天 | 內建 |
| 電子發票串接 | 1 天 | 1 天 | 2 天 | 2 天 | N/A |
| Sandbox 測試 | 1 天 | 1.5 天 | 2 天 | 1 天 | 0.5 天 |
| 對帳 + 退款 SOP | 1 天 | 1 天 | 1.5 天 | 1 天 | 0.5 天 |
| 小計 | ~7.5 天 | ~8.5 天 | ~13 天 | ~10 天 | ~3.5 天 |
⚠️ 注意:
- 雙軌(綠界 + Stripe)= 約 11 天,但抽象層額外 2-3 天
- 上述為「順利情況」,踩雷會 1.5-2 倍
- 不含設計、PM 對齊、測試 QA、code review
7. Sandbox / 測試環境
| 業者 | Sandbox 完整度 | 測試卡號 | 模擬 webhook | Local 開發 |
|---|---|---|---|---|
| 綠界 | ✅ 完整 | 官方提供 | ✅ 後台可手動觸發 | 需 ngrok |
| 藍新 | ✅ 完整 | 官方提供 | ⚠️ 較難主動觸發 | 需 ngrok |
| 紅陽 | ⚠️ 有但較陽春 | 部分需申請 | ⚠️ 較弱 | 需 ngrok |
| TapPay | ✅ 完整 | 官方提供 | ✅ 可重送 | 需 ngrok |
| Stripe | ✅ 業界標竿 | 數十種測試卡涵蓋各種錯誤情境 | ✅ CLI 可直接轉發到 localhost | Stripe CLI 直接打進 localhost,不用 ngrok |
Stripe CLI 是工程體驗的代差:
stripe listen --forward-to localhost:3000/webhook一行,Webhook 直接打進你本機,不用對外暴露端口。
8. 推薦架構(PaymentProvider 抽象)
不管最後選哪家,這個抽象層都該做:
// packages/payment-core/src/types.ts
export type CreateCheckoutInput = {
userId: string
planId: string
email: string
country: 'TW' | string
successUrl: string
cancelUrl: string
}
export type CheckoutResult = {
checkoutUrl: string
provider: 'stripe' | 'ecpay'
sessionId: string
}
export interface PaymentProvider {
readonly name: 'stripe' | 'ecpay' | 'newebpay' | 'tappay'
// 訂閱
createCheckoutSession(input: CreateCheckoutInput): Promise<CheckoutResult>
cancelSubscription(subId: string, mode: 'immediate' | 'at_period_end'): Promise<void>
updatePaymentMethod(customerId: string): Promise<{ updateUrl: string }>
// 一次性收款
createOneTimeCharge(input: CreateChargeInput): Promise<{ payUrl: string; chargeId: string }>
voidCharge(chargeId: string): Promise<void>
// Webhook
verifyWebhook(rawBody: Buffer | string, signature: string): WebhookEvent
// 共用
retrieveInvoice(invoiceId: string): Promise<Invoice>
}
// packages/payment-core/src/factory.ts
export function getProvider(country: string): PaymentProvider {
return country === 'TW'
? new ECPayProvider(config.ecpay)
: new StripeProvider(config.stripe)
}
業務邏輯只認 interface,不認 provider。換家時改 factory 就好。
9. 工程面最終建議
9.1 主推:綠界 + Stripe
理由:
- 綠界踩雷時 StackOverflow / 鐵人賽 / Medium 文章最多
- Stripe 工程體驗無對手
- 兩家都有完整 Sandbox
9.2 不推:紅陽
工程面有明顯的代差,新團隊上手痛、文件落後、社群案例少。除非有強烈商業綁定理由,不該選。
9.3 TapPay 適合的場景
如果橋外生未來轉向 B2C 一次性購買(例如求職者付費下載報告、買履歷加值),TapPay 工程體驗會明顯好過綠界。但現階段 B2B 訂閱不選。
9.4 必做:抽象層 + Webhook idempotency
不論選哪家,這兩件事都要做:
- PaymentProvider 抽象:避免業務邏輯綁死,未來換家成本可控
- Webhook 冪等性處理:用
event_id+processed_eventstable,重送也不會重複扣款
10. Sprint 0 工程清單
選定金流後第一週要做的:
- 申請 Sandbox 帳號(綠界 / Stripe 各一)
- 申請正式商戶帳號(流程跑 1-2 週)
- 環境變數結構設計(dev / staging / prod 三組金鑰)
- PaymentProvider interface 定義
-
paymentschema 設計(subscription、charge、invoice、webhook_event 四張表) - Webhook idempotency 機制(用
webhook_event_id鎖) - Sandbox 測試卡號清單收集
- Stripe CLI 安裝(如選 Stripe)
- 對帳排程框架(cron job 預留)
- 監控 metric 規劃(checkout 成功率、webhook 成功率)
11. 參考連結
綠界
- 官網:https://www.ecpay.com.tw/
- API 文件:https://developers.ecpay.com.tw/
- Sandbox:https://vendor-stage.ecpay.com.tw/
- Node SDK(社群):https://github.com/ECPay/ECPayAIO_Node.js
藍新
- 官網:https://www.newebpay.com/
- API 文件:https://www.newebpay.com/website/Page/content/download_api
- Sandbox:https://ccore.newebpay.com/
紅陽
- 官網:https://www.esafe.com.tw/
- API 文件:需開戶後索取