@paid-tw/payment-ecpay
v0.3.0
Published
ECPay 綠界 adapter for @paid-tw/payment — AIO (redirect) + 站內付 2.0 ECPG (token) + 非信用卡幕後取號 (ATM/CVS/BARCODE) + 信用卡幕後授權 (BackAuth).
Readme
@paid-tw/payment-ecpay
ECPay 綠界 All-in-One adapter for @paid-tw/payment.
Implements PaymentProvider with:
- createPayment → AioCheckOut V5 redirect form (
mode: "redirect"; alwaysNeedExtraPaidInfo=Y) - getPayment → QueryTradeInfo/V5
- refundPayment / capturePayment / cancelClose / abandonPayment → DoAction R/C/E/N
- creditDoAction → low-level DoAction
- queryCreditTrade → CreditDetail/QueryTrade/V2(需
creditCheckCode) - verifyPaymentNotify → ReturnURL / OrderResultURL CheckMacValue verify
Usage
import { createEcpayProvider, ECPAY_SANDBOX, ECPAY_NOTIFY_ACK } from "@paid-tw/payment-ecpay";
const ecpay = createEcpayProvider({
...ECPAY_SANDBOX, // or your production credentials
});
// 1) Create — returns a redirect form, never "already paid"
const form = await ecpay.createPayment({
amount: 1000,
currency: "TWD",
method: "card",
orderId: "ORDER123",
notifyUrl: "https://example.com/ecpay/notify", // ReturnURL
returnUrl: "https://example.com/ecpay/result", // OrderResultURL (optional)
});
// form.mode === "redirect" — auto-submit form.action + form.params in the browser.
// 2) ReturnURL handler (server POST from ECPay)
// body = application/x-www-form-urlencoded fields
app.post("/ecpay/notify", (req, res) => {
const notify = ecpay.verifyPaymentNotify(req.body);
if (notify.success && !notify.simulated) {
// mark order paid (idempotent on notify.merTradeNo)
}
res.type("text/plain").send(ECPAY_NOTIFY_ACK); // must be exactly "1|OK"
});公開測試特店(stage)
綠界在文件公布的模擬銀行 3D 驗證 / 中租無卡分期測試特店(明碼,可直接用於整合測試):
| 項目 | 值 |
| ---------- | ------------------------------------ |
| MerchantID | 3002607 |
| HashKey | pwFHCqoQZGmho4w6 |
| HashIV | EkRm7iFT261dpevs |
| 統一編號 | 00000000 |
| 後台帳號 | stagetest3 |
| 後台密碼 | test1234 |
| 金流 stage | https://payment-stage.ecpay.com.tw |
| 特店後台 | https://vendor-stage.ecpay.com.tw |
SDK 常數:ECPAY_SANDBOX、ECPAY_SANDBOX_PORTAL(後台登入僅供人工 QA,API 不用)。
手動刷卡測試(stage 收銀台):卡號 4311-9522-2222-2222,3D OTP 1234。
測試:MSW + live
| 模式 | 指令 | 說明 |
| ------------------ | -------------------------------------------------------------- | --------------------------------------------------------------- |
| MSW(預設 CI) | pnpm test 或 pnpm --filter @paid-tw/payment-ecpay test:msw | 離線;用錄製的 stage 回應 + 同一組 3002607 金鑰驗 CheckMacValue |
| Live | ECPAY_LIVE=1 pnpm test:live:ecpay | 打真實 payment-stage;預設即用公開特店,不必設 env |
# 離線(MSW)
pnpm --filter @paid-tw/payment-ecpay test:msw
# 真實 stage
ECPAY_LIVE=1 pnpm test:live:ecpay
# 查一筆你在 stage 完成付款的訂單 + 印 raw
ECPAY_LIVE=1 ECPAY_QUERY_ID=yourMerTradeNo PAID_DEBUG=1 pnpm test:live:ecpayMSW 的 default handlers 會對已知 MerchantTradeNo 重放 field-exact fixtures(與 live 同一組 HashKey/HashIV),方便在無網路時重現 stage 行為。
AIO 其他參數(passthrough)
createPayment 除了必填欄位外,把 AIO 的 13 個共同選填參數做成具名欄位,其餘(各付款
方式自己的參數)走 params 逃生口:
const form = await ecpay.createPayment({
amount: 321,
currency: "TWD",
method: "atm",
orderId: "ORDER123",
notifyUrl: "https://shop/notify",
// 具名的共同參數
storeId: "S1",
remark: "備註",
language: "ENG",
customField1: "c1",
ignorePayment: "Credit#WebATM",
// 取號通知(見下節)
paymentInfoUrl: "https://shop/paid-info",
// 各付款方式自己的參數走這裡
params: { ExpireDate: 7 }, // ATM 繳費期限
// params: { CreditInstallment: "3" } // 信用卡分期
// params: { StoreExpireDate: 10080, Desc_1: "..." } // 超商代碼
});為什麼不是把每個付款方式都做成具名參數:各方式的參數有數十個、還會增加,逐個列舉等於
綠界每加一個欄位這個套件就要發一版。params 讓所有 AIO 欄位立刻可用。
⚠️ CheckMacValue 是在合併 params 之後才計算的 —— 沒被簽進去的欄位綠界會擋,而且
看不出原因。另外 adapter 自己產生或簽章的欄位(MerchantID、MerchantTradeNo、
TotalAmount、ReturnURL、PaymentType、EncryptType、ChoosePayment、
MerchantTradeDate、CheckMacValue)不允許用 params 覆寫,會直接丟
VALIDATION —— 一個要簽章的欄位有兩個來源,就是 MAC 對不上的原因。
AIO 取號結果通知(PaymentInfoURL)
這是 AIO 版的「拿到繳費資訊」—— 消費者還是走綠界頁面,但訂單建立時(不是付款完成 時)綠界就會把虛擬帳號/繳費代碼/條碼 server-post 給你:
import { verifyEcpayPaymentInfoNotify, ECPAY_NOTIFY_ACK } from "@paid-tw/payment-ecpay";
app.post("/ecpay/paid-info", (req, res) => {
const info = verifyEcpayPaymentInfoNotify(req.body, {
hashKey,
hashIv,
merchantId,
});
if (info.success) {
// info.atm?.vAccount / info.cvs?.paymentNo / info.barcode?.barcode1..3
}
res.type("text/plain").send(ECPAY_NOTIFY_ACK); // "1|OK"
});⚠️ 一定要用 verifyEcpayPaymentInfoNotify,不要用 verifyPaymentNotify。 取號成功的
RtnCode 是 2(ATM)/10100073(CVS/BARCODE),不是 1 —— 拿付款結果的驗證器去
驗取號通知,成功的取號會被判成 success: false。兩者傳輸方式(form + CheckMacValue)
相同,但成功碼和欄位集都不同。
想完全不讓消費者看到綠界頁面的話,用非信用卡幕後取號那條。
四套 API 與區隔方式
綠界金流有四條產品線,同一 npm 套件、四個 factory、四個 name(詳見
docs/ecpay-provider-separation.md):
| 系列 | Factory | name | Host | create 結果 |
| --------------------- | ---------------------------------------------------- | ------------------ | ------------------ | --------------------------------------------- |
| 全方位金流 (AIO) | createEcpayProvider | "ecpay" | payment.ecpay… | { mode: "redirect", action, params } |
| 站內付 2.0 (ECPG) | createEcpayEcpgProvider | "ecpay-ecpg" | ecpg.ecpay… | { mode: "token", token, merchantTradeNo } |
| 非信用卡幕後取號 | createEcpayPayCodeProvider | "ecpay-paycode" | ecpayment.ecpay… | { mode: "paycode", atm/cvs/barcode } |
| 信用卡幕後授權 ⚠️ | createEcpayBackAuthProvider(/backauth subpath) | "ecpay-backauth" | ecpayment.ecpay… | { mode: "3ds" } 或 { mode: "authorized" } |
import { createEcpayEcpgProvider, ECPAY_SANDBOX } from "@paid-tw/payment-ecpay";
const ecpg = createEcpayEcpgProvider({ ...ECPAY_SANDBOX });
// 1) Server: GetTokenbyTrade
const { token, merchantTradeNo } = await ecpg.createPayment({
amount: 100,
currency: "TWD",
method: "card",
orderId: "ORDER123",
notifyUrl: "https://example.com/notify",
email: "[email protected]",
});
// 2) Browser: ECPay JS SDK createPayment(token) → getPayToken()
// 3) Server: CreatePayment
const paid = await ecpg.createPaymentWithPayToken({
payToken: "...",
merchantTradeNo,
});
// paid.threeDUrl? → full-page 3DS; or atm/cvs take-number fields
// 4) ReturnURL — JSON + AES Data(與 AIO form CheckMac 不同)
app.post("/ecpay/ecpg/notify", (req, res) => {
const notify = ecpg.verifyPaymentNotify(req.body);
if (notify.success && !notify.simulated) {
// mark paid (idempotent on notify.merTradeNo)
}
res.type("text/plain").send(ECPG_NOTIFY_ACK); // "1|OK"
});前端 JS 與樣式不在此 Node SDK 範圍內,請依綠界站內付 2.0 Web 文件載入官方 SDK。
非信用卡幕後取號(背景取號)
不跳轉綠界頁面,後端直接拿到 ATM 虛擬帳號 / 超商代碼 / 超商條碼,再自行交付消費者 (Email / SMS / 自家頁面)。信用卡不走這條,請用幕後授權 BackAuth。
import {
createEcpayPayCodeProvider,
ECPAY_PAYCODE_NOTIFY_ACK,
ECPAY_SANDBOX,
} from "@paid-tw/payment-ecpay";
const paycode = createEcpayPayCodeProvider({ ...ECPAY_SANDBOX });
// 取號:回應就帶著繳費資訊,沒有 redirect
const atm = await paycode.createPayment({
amount: 1234,
currency: "TWD",
method: "atm", // "atm" | "cvs" | "barcode"
orderId: "ORDER123",
itemDesc: "測試商品",
notifyUrl: "https://example.com/ecpay/paycode/notify", // ReturnURL,必填
expireDate: 3, // ⚠️ 單位依付款方式而異,見下表
atmBankCode: "822", // ATM 選填:指定繳費銀行
});
// atm.atm → { bankCode: "822", vAccount: "9251…", expireDate: "2026/08/04" }
// atm.status === "unpaid" — 取號成功不等於已付款
// 查詢:訂單狀態 vs 繳費資訊是兩支 API
const state = await paycode.getPayment({ merTradeNo: "ORDER123" }); // QueryTrade
const code = await paycode.getPaymentCode({ merTradeNo: "ORDER123" }); // QueryPaymentInfo
// ReturnURL — AES-JSON 進來,但要回純字串 "1|OK"
app.post("/ecpay/paycode/notify", (req, res) => {
const notify = paycode.verifyPaymentNotify(req.body);
if (notify.success && !notify.simulated) {
// mark paid (idempotent on notify.merTradeNo)
}
res.type("text/plain").send(ECPAY_PAYCODE_NOTIFY_ACK); // "1|OK"
});各付款方式差異
| method | ChoosePayment | expireDate 單位 | 預設 | 範圍 | 回傳 |
| ----------- | ------------- | ----------------- | ----- | ------- | ---------------------------------- |
| "atm" | ATM | 天 | 3 | 1–60 | atm.bankCode + atm.vAccount |
| "cvs" | CVS | 分鐘 | 10080 | 1–43200 | cvs.paymentNo + cvs.paymentUrl |
| "barcode" | BARCODE | 天 | 7 | 1–30 | barcode.barcode1/2/3 |
expireDate 單位不同是最容易踩的雷,adapter 會先驗證再送出——把 CVS 的分鐘數當成
ATM 天數傳,會拿到完全不同期限的帳號。超過 30 天需另向綠界申請特約賣家。
使用時要知道的事
- 無線上退款:ATM / 超商代碼 / 超商條碼都是消費者付現,綠界沒有退款 API,
refundPayment()一律丟UNSUPPORTED。要退款請走廠商後台人工處理。 - 繳費資訊只在取號時回傳一次,務必自行保存;忘了存只能用
getPaymentCode()(QueryPaymentInfo)補回來——getPayment()(QueryTrade)的ATMInfo是「付款人」帳號後五碼,不是虛擬帳號。 - 超商條碼的付款通知會延遲約 2 天(超商端作業時間),barcode 訂單卡在未付款 不代表消費者沒繳。
SimulatePaid: 1代表這是廠商後台按「模擬付款」發出的測試通知,綠界不會撥款, 出貨就是實際損失——所以判斷條件是success && !simulated。- 不要用
TradeStatus判斷付款成功。實測模擬付款的通知是RtnCode: 1+ 真的PaymentDate,但TradeStatus還是"0"(綠界明說模擬付款不改付款狀態), 拿TradeStatus === "1"當條件會直接漏掉這筆通知。 - 條碼只回三段號碼,不回圖檔,需自行轉 Code39。
barcode1不是純數字 (實測1508086CY)。
超商代碼轉三段式條碼
消費者不想在超商機台輸入代碼時,可以把 paymentNo 轉成可掃的三段條碼:
const bar = await paycode.getCvsBarcode({
paymentNo: "LLL26213917403",
chain: "iBon",
});
// bar.barcode1/2/3 + bar.expireDate⚠️ 每家超商的條碼不一樣(實測:同一個 paymentNo 三家回傳的 Barcode1/Barcode3
全不同,Barcode2 在 iBon 是一組 token、全家/萊爾富則是補零後的代碼)。所以要先知道
消費者去哪家,不能拿一家的條碼去另一家用。
其他限制:chain 只支援 Family / Hilife / iBon(取號時的 CVS、OK 不支援
轉條碼;而且注意大小寫是 iBon 不是取號用的 IBON),訂單已付款或已過期會失敗,
每次轉換有效 10 分鐘。
下載撥款對帳檔
const media = await paycode.downloadTradeMedia({
dateType: "1", // 1=結算日期 2=撥款日期
beginDate: "2026-07-01",
endDate: "2026-07-31", // 區間最大 1 個月
paymentType: "04", // 選填:03 ATM / 04 超商代碼 / 05 超商條碼
});
import { parseTradeMediaCsv } from "@paid-tw/payment-ecpay";
const rows = parseTradeMediaCsv(media.csv);⚠️ 這支 API 回傳 CSV,不是 AES 信封,而且每個欄位都被包成 ="值"(Excel 強制
文字的寫法,避免長交易編號被轉成科學記號)。直接 split(",") 會拿到字面上帶
="…" 的內容,請用 parseTradeMediaCsv()。
實測還有兩點文件沒寫:真實檔案有第 13 個欄位 金流處理費(文件只列 12 個),
Content-Type 是 text/plain。查無資料時回傳只有標題列,不是錯誤。
綠界端另有限制:呼叫 IP 需在廠商後台加白名單(系統開發管理 → 系統介接設定), 且一分鐘只能下載一個檔。呼叫太快會拿到 HTTP 403,要等 30 分鐘。
錄製真實的付款通知
通知沒辦法用測試觸發——綠界只在真的有人繳費、或有人在後台按「模擬付款」時才發, 而且只發到公開可達的 HTTPS 網址。要重新錄製:
pnpm capture:ecpay-notify # :8787,收到就解密印出,並回 "1|OK"
cloudflared tunnel --url http://localhost:8787 # 另一個 shell,取得公開 URL然後把 notifyUrl 指向該 URL 取號,再到
vendor-stage 的
一般訂單查詢 → 全方位金流訂單 找到那筆訂單按「模擬付款」。腳本印出來的內容可以
直接貼進 paycode-fixtures.ts。
⚠️ 模擬付款不會產生 TradeStatus: "1",也不會帶繳費門市——那個形狀只有真的去
超商繳費才拿得到。
完整缺口表:docs/ecpay-api-coverage.md。
信用卡幕後授權(BackAuth)⚠️ 收原始卡號
先讀這段:PCI-DSS 範圍
這是本套件唯一會碰到原始卡號的 adapter。 其他三條(AIO、站內付 2.0、幕後取號)卡號 都不會經過你的主機,所以你落在 PCI-DSS SAQ A;一旦自己收卡號,就變成 SAQ D, 稽核與基礎架構的要求完全不同等級。
範圍是由「你是否處理卡號」決定,不是由「程式碼在不在 bundle 裡」決定 —— 所以只要
不呼叫 createEcpayBackAuthProvider,你仍然在 SAQ A。
不過 BackAuth 不從套件根目錄匯出,而是放在自己的 subpath:
import { createEcpayBackAuthProvider } from "@paid-tw/payment-ecpay/backauth";這樣你可以只靠 import graph 就證明某個 app 不含 raw-PAN 介面 —— 稽核時是個機械可查的
答案,而不是一句「我們沒有用到」。根目錄的 @paid-tw/payment-ecpay 只有另外三條卡號不
經手的 adapter。
請確定你真的需要「後端直接拿卡號授權、消費者不看任何付款頁」這個能力,而不是因為它 用起來比較方便。如果只是要收信用卡,用 AIO 或站內付 2.0。
綠界端另有前置條件:需申請關閉 OTP 並申請開啟信用卡 3D 驗證才能使用。
用法
// 注意 import 路徑:BackAuth 在 /backauth subpath,不在套件根目錄
import { createEcpayBackAuthProvider, ECPAY_SANDBOX_NO_3D } from "@paid-tw/payment-ecpay/backauth";
const backauth = createEcpayBackAuthProvider({ ...ECPAY_SANDBOX_NO_3D });
const result = await backauth.createPayment({
amount: 199,
currency: "TWD",
method: "card",
orderId: "ORDER123",
itemDesc: "測試商品",
notifyUrl: "https://example.com/ecpay/backauth/notify",
orderResultUrl: "https://example.com/ecpay/backauth/result", // ⚠️ 必填,見下
card: {
cardNo: "4311952222222222",
expiryMonth: "12",
expiryYear: "30",
cvv: "222",
},
phone: "886912345678",
cardholderName: "TEST USER",
});
// ⚠️ 一定要先看 mode,不要先看 RtnCode
if (result.mode === "3ds") {
redirectFullPage(result.threeDUrl); // 不可用 iframe
} else {
console.log(result.success, result.card?.card4No, result.card?.gwsr);
}四個實測踩到的雷
- 3D 驗證的回應「沒有
RtnCode」。 只有ThreeDURL、MerchantID、MerchantTradeNo三個欄位。文件 45958 的 3D 章節有列 RtnCode,所以「先檢查RtnCode === 1、再看 ThreeDURL」這個最直覺的寫法會把正常的 3DS 轉導判成失敗。 這就是回傳值設計成 discriminated union 的原因。 OrderResultURL實際上必填,文件沒標必填。沒帶會拿到RtnCode 5000029,連 3D 關閉的特店也一樣。MerchantID在 3D 分支是數字、在授權分支是字串。 同一支 API 同一個欄位。- 刷卡失敗回
RtnCode 10100058,而這個號碼在「幕後取號」的代碼表裡是 「ATM 繳費期限已過」。 綠界的錯誤碼跨服務會撞號,所以錯誤表必須分服務維護。
請退款只有正式環境
creditDoAction()(C 關帳 / R 退刷 / E 取消 / N 放棄)與 refundPayment()
只能在正式環境用 —— 綠界明講測試環境無法提供實際授權、因此不開放這支 API。所以
sandbox 設定下 adapter 會直接丟 UNSUPPORTED,不會發一個註定 404 的請求。
refundPayment() 需要你自己帶 tradeNo(綠界交易編號),不會偷偷先查一次 —— 退款
路徑上多一次查詢值得講清楚。tradeNo 從授權結果(result.tradeNo)或 notify
(notify.tradeNo)拿,請保存它。
⚠️ 別跟 gwsr 搞混:gwsr(notify 的 creditRefundId)是銀行授權碼,DoAction
不吃這個欄位(文件 45919 的請求參數只有 MerchantID / MerchantTradeNo / TradeNo /
Action / TotalAmount)。gwsr 是「信用卡單筆明細查詢」和對帳用的 —— 那支 API 現在是
queryCreditDetail()(回傳的 tradeId 就等於這個 gwsr)。
定期定額
定期定額沒有獨立的建立 API:它就是一般的 BackAuth,在 CardInfo 裡多帶四個欄位。
查詢也還是同一支 QueryTrade,只是回應多幾個欄位。所以真正新增的端點只有
CreditCardPeriodAction。
const result = await backauth.createPayment({
amount: 300, // 首期金額
currency: "TWD",
method: "card",
orderId: "SUB123",
itemDesc: "月訂閱",
notifyUrl: "https://example.com/ecpay/backauth/notify",
orderResultUrl: "https://example.com/ecpay/backauth/result",
card: {
cardNo: "4311952222222222",
expiryMonth: "12",
expiryYear: "30",
cvv: "222",
},
phone: "886912345678",
cardholderName: "TEST USER",
// 每 1 個月扣一次,共 12 期(含當下這期)
period: { amount: 300, type: "M", frequency: 1, execTimes: 12 },
});
// ⚠️ 第 1 期在這裡就已經扣掉了
console.log(result.period?.totalSuccessTimes); // 1
// 查進度(含每期明細)
const order = await backauth.queryPeriodOrder({ merTradeNo: "SUB123" });
console.log(order.isActive, order.period?.totalSuccessAmount);
for (const exec of order.executions) {
console.log(exec.processDate, exec.amount, exec.tradeNo); // 每期各有自己的 tradeNo
}
// 停用(不可逆)
await backauth.creditCardPeriodAction({ orderId: "SUB123", action: "Cancel" });type / frequency / execTimes 的合法範圍(adapter 會在送出前擋掉):
| type | frequency | execTimes |
| ------ | ----------- | ----------- |
| "D" | 1–365 天 | 2–999 |
| "M" | 1–12 月 | 2–999 |
| "Y" | 只能 1 | 2–99 |
定期定額實測踩到的雷
execTimes最小是 2,不是 1。 文件 9093 寫「2-999」看起來像筆誤(旁邊 Frequency 是 1 開始),但送 1 真的會被拒。- 第 1 期在建立當下就扣款了。 建立的回應裡
TotalSuccessTimes已經是 1、也有Gwsr。所以execTimes: 12是「現在這期 + 之後 11 期」,不是「未來 12 期」。測試會 真的產生一筆扣款。 ExecLog文件完全沒寫,但它是唯一的每期明細。 計數器只告訴你「成功幾期」,只有ExecLog告訴你「哪幾期、什麼時候、多少錢、哪個TradeNo」—— 對帳需要的正是後者。 本 adapter 把它開成order.executions。ExecStatus文件也沒寫,它才是「這個定期定額還在跑嗎」的答案("1"執行中、"0"已停用)。開成order.isActive。不能用status/TradeStatus代替 —— 停用後它還是"1"(已付款),因為第 1 期真的扣成功了。- 期別欄位放在
CardInfo裡(request 和 response 都是),沒有PeriodInfo這種 容器。放錯不會報錯:綠界會當成一般單筆授權,而你以為定期定額建好了。 Cancel不可逆,沒有恢復的 API。停用後再ReAuth會拿到100006 該訂單狀態為停用中(adapter 對應成CONFLICT,不是可重試的錯誤)。Cancel成功訊息是中文「停用成功」,而同一個服務其他端點回英文"Succeeded."。 所以判斷成功只能看RtnCode,永遠不要比對RtnMsg文字。
⚠️ 測試定期定額會留下會持續扣款的訂單,而綠界沒有刪除訂單的 API。本套件的 live
測試因此只開最小的排程(Y/1/2 期、5 元),在 afterAll 停用(即使測試中途失敗也
會停),停用後還會再查一次確認,沒停掉就直接把訂單編號印出來讓你手動處理。
測試卡與測試特店
ECPAY_TEST_CARD = 4311952222222222 / CVV 222(綠界公開,非真卡)。有效月年必須
晚於當下,所以測試要自己算,不要寫死年份。
ECPAY_SANDBOX_NO_3D = 特店 2000132(3D 關閉)。要測「直接授權成功」必須用它 ——
預設的 ECPAY_SANDBOX(3002607)3D 是開的,每次都只會回 ThreeDURL。
⚠️ adapter 刻意不做 Luhn 檢查:綠界自己的測試卡號 Luhn 是不通過的,加了會讓官方 測試卡不能用。只驗長度與數字。
