This is the full developer documentation for 應援金流(OEN Payment)
# 應援金流文件
> 不管你是要開通收款的商家,還是負責串接的工程師,都從這裡開始。
## 我想要…
[Section titled “我想要…”](#我想要)
輸入關鍵字,或點「我是商家」「我是工程師」篩選。每一項都同時列出商家在後台怎麼做、工程師怎麼串接。
搜尋你想做的事輸入關鍵字,例如:退款、定期、403、發票
全部我是商家我是工程師
* 開始收款、開通信用卡或其他付款方式[商家怎麼做](/merchant/activate/)[工程師怎麼做](/developers/payment-methods/)
* 讓消費者付一筆款(購物、捐款、報名)[商家怎麼做](/merchant/payment-experience/)[工程師怎麼做](/developers/one-time/)[API](/api/checkout/)
* 讓消費者用超商代碼繳費[商家怎麼做](/merchant/payment-experience/)[工程師怎麼做](/developers/payment-methods/)[API](/api/checkout/)
* 每個月自動扣款(定期定額、會員月費)[商家怎麼做](/merchant/subscriptions/)[工程師怎麼做](/developers/subscriptions/)[API](/api/checkout-subscription/)
* 從指定日期才開始扣款[商家怎麼做](/merchant/subscriptions/)[工程師怎麼做](/developers/subscriptions/)[API](/api/checkout-schedule/)
* 記住消費者的卡片,之後由我決定何時扣款[工程師怎麼做](/developers/saved-cards/)[API](/api/checkout-token/)
* 查一筆付款成功了沒[商家怎麼做](/merchant/transactions/)[工程師怎麼做](/developers/querying/)[API](/api/get-transaction/)
* 列出一段時間的交易來對帳[商家怎麼做](/merchant/transactions/)[工程師怎麼做](/developers/querying/)[API](/api/list-transactions/)
* 退款給消費者[商家怎麼做](/merchant/refunds/)[工程師怎麼做](/developers/refunds/)[API](/api/refund/)
* 取消定期定額[商家怎麼做](/merchant/subscriptions/)[工程師怎麼做](/developers/subscriptions/)[API](/api/cancel-subscription/)
* 付款完成後自動通知我的系統[工程師怎麼做](/developers/webhooks/)[API](/api/objects/webhook/)
* 拿到串接用的 API token[商家怎麼做](/merchant/api-access/)[工程師怎麼做](/developers/authentication/)
* 正式環境呼叫 API 被擋(403)、申請固定 IP[商家怎麼做](/merchant/api-access/)[工程師怎麼做](/developers/ip-allowlist/)
* 在測試環境試刷[工程師怎麼做](/developers/environments/)
* 看懂 API 回的錯誤碼[工程師怎麼做](/developers/errors/)[API](/api/error-codes/)
* 付款結果不確定,不想重複扣款[工程師怎麼做](/developers/errors/)
* 開立或作廢電子發票、退款折讓[商家怎麼做](/merchant/invoices/)[工程師怎麼做](/developers/one-time/)
* 我的網站是 WordPress/WooCommerce[商家怎麼做](/merchant/woocommerce/)
* 付款欄位要嵌在自己的頁面[工程師怎麼做](/products/embed/)
* 請 AI 助手幫我寫串接程式[工程師怎麼做](/ai/)
* 上線前最後確認[商家怎麼做](/merchant/api-access/)[工程師怎麼做](/developers/go-live/)
找不到符合的項目。試試右上角的全站搜尋,或看[常見問題](/start/faq/)。
## 依身分開始
[Section titled “依身分開始”](#依身分開始)
商家、營運、客服
不用寫程式。開通付款方式、看懂消費者的付款流程、查交易、退款、管理定期定額與發票,都在 CRM 後台完成。
* [商家指南總覽](/merchant/)
* [開通金流與付款方式](/merchant/activate/)
* [交給工程師串接前的準備](/merchant/api-access/)
工程師
用 Payment API 建立結帳頁、定期定額與存卡扣款,接收付款通知並處理退款。範例有 cURL、Node.js、PHP、Python。
* [快速開始](/developers/quickstart/)
* [API 參考](/api/)
* [上線檢查清單](/developers/go-live/)
## 還不確定要用哪一種?
[Section titled “還不確定要用哪一種?”](#還不確定要用哪一種)
[我該用哪一種收款方式?](/start/choose/)回答兩三個問題,找到適合你的串接方式。
## 產品一覽
[Section titled “產品一覽”](#產品一覽)
| 產品 | 適合 | 狀態 |
| ----------------------------------------------- | ------------------------------- | ----- |
| [Payment API](/developers/quickstart/) | 自己的網站或 App:單次付款、定期定額、存卡扣款、查詢與退款 | 可直接使用 |
| [WooCommerce 外掛](/merchant/woocommerce/) | WordPress 商家,不用寫程式 | 需開通 |
| [Embed 嵌入式付款](/products/embed/) | 信用卡表單嵌在自己的頁面 | 需開通 |
| [Subscription API](/products/subscription-api/) | 有試用期、方案變更等完整訂閱管理 | 需開通 |
| [Payment Skill 與 Payment MCP](/ai/) | 讓 AI 助手幫你寫串接程式 | 見各頁說明 |
## 給 AI 讀
[Section titled “給 AI 讀”](#給-ai-讀)
整站內容也提供純文字版,貼給 AI 助手就能根據最新文件回答:[`/llms.txt`](/llms.txt)、[`/llms-full.txt`](/llms-full.txt)。詳見[用 AI 助手串接](/ai/)。
# 我該用哪一種收款方式?
> 回答兩三個問題,找到適合你的串接方式;下方的比較表列出各方式的差異。
先用問答找到方向,再用下方的比較表確認細節。
1\. 你打算怎麼收款?
我的網站是 WordPress + WooCommerce裝外掛就能收款,不用寫程式我有自己的網站或 App,有工程師可以串接自己的團隊或外包我還沒有工程師,想先了解流程先看商家指南,準備好再交給工程師
2\. 要收哪一種款項?
一次付清購物、捐款、活動報名固定金額、固定週期自動扣款會員月費、定期捐款先存卡,之後由我決定何時扣、扣多少儲值、隨用隨扣、快速結帳
3\. 付款畫面要放在哪裡?
把消費者導到應援的結帳頁最快上線,可收信用卡、超商代碼等嵌在我自己的頁面,消費者不離開網站目前只支援信用卡
建議你用
Payment API:單次付款結帳頁建議
你的伺服器呼叫 `POST /checkout` 取得結帳頁,把消費者導過去付款。付款結果以付款通知與查詢 API 確認。
[快速開始 →](/developers/quickstart/)[單次付款指南 →](/developers/one-time/)[API 參考 →](/api/checkout/)
建議你用
Payment API:定期定額建議
消費者在結帳頁綁卡並同意扣款,之後由應援依週期自動扣款。要從指定日期才開始扣,用預約定期定額。
[定期定額指南 →](/developers/subscriptions/)[建立定期定額 →](/api/checkout-subscription/)[建立預約定期定額 →](/api/checkout-schedule/)
建議你用
Payment API:存卡與後續扣款
消費者在綁卡頁完成 3D 驗證後,你會拿到一組 token,之後由你的伺服器用 token 扣款,消費者不必在場。
[存卡與後續扣款指南 →](/developers/saved-cards/)[建立綁卡頁 →](/api/checkout-token/)[用 token 扣款 →](/api/token-transactions/)
建議你用
Embed 嵌入式付款需開通
信用卡表單嵌在你自己的頁面。要先請應援開通,目前只支援信用卡。
[Embed 說明 →](/products/embed/)
建議你用
WooCommerce 外掛需開通
在 WordPress 後台安裝外掛、填入金鑰就能收款,不用寫程式。
[安裝與設定 →](/merchant/woocommerce/)
建議你用
先看商家指南
了解開通流程、消費者付款畫面、退款與對帳,再把「交給工程師串接前的準備」這頁轉給你的工程師。
[商家指南總覽 →](/merchant/)[交給工程師串接前的準備 →](/merchant/api-access/)
重新選擇
## Payment API 的三種收款流程
[Section titled “Payment API 的三種收款流程”](#payment-api-的三種收款流程)
大多數商家用 Payment API 就夠了。它有三種流程,差別在「誰決定什麼時候扣款」:
| | 單次付款 | 定期定額 | 存卡與後續扣款 |
| ------- | ----------------------------- | ---------------------------------- | ----------------------------------- |
| 適合 | 購物、捐款、活動報名 | 會員月費、定期捐款 | 儲值、隨用隨扣、快速結帳 |
| 消費者要做的事 | 在結帳頁付款 | 在結帳頁綁卡並同意扣款 | 在綁卡頁完成 3D 驗證 |
| 之後誰扣款 | 不再扣款 | 應援依週期自動扣款 | 你的伺服器呼叫 API 扣款 |
| 金額 | 每次建立時決定 | 固定 | 每次扣款時決定 |
| 開始 | [單次付款](/developers/one-time/) | [定期定額](/developers/subscriptions/) | [存卡與後續扣款](/developers/saved-cards/) |
## 各種方式比較
[Section titled “各種方式比較”](#各種方式比較)
表格可以左右滑動。
| | Payment API | WooCommerce 外掛 | Embed 嵌入式付款 | Subscription API |
| ----- | ------------------------------- | ------------------------------- | ------------------------- | ----------------------------------------------- |
| 狀態 | 可直接使用 | 需開通 | 需開通 | 需開通 |
| 適合 | 自己的網站或 App | WordPress 商家 | 付款欄位嵌在自己頁面 | 需要試用期、方案變更等訂閱管理 |
| 要寫程式嗎 | 要 | 不用 | 要(前端與後端) | 要 |
| 付款畫面 | 應援結帳頁 | 應援結帳頁 | 你的頁面 | 見產品頁 |
| 驗證 | CRM 產生的 token | 外掛設定頁填入金鑰 | `pk_` 與 `sk_` 金鑰 | `sub_sk_` 金鑰 |
| 付款通知 | 沒有簽章,收到後回查 | 外掛自動處理 | 有簽章 | 有簽章 |
| 說明 | [快速開始](/developers/quickstart/) | [安裝與設定](/merchant/woocommerce/) | [Embed](/products/embed/) | [Subscription API](/products/subscription-api/) |
## 已經在用 Payment API?
[Section titled “已經在用 Payment API?”](#已經在用-payment-api)
Payment API 是應援主要支援的串接方式,會持續維護並加入新功能。這一版文件依正式環境 v10.3.1.1 更新,差異見[更新紀錄](/changelog/)。
# 常見問題
> 商家與工程師最常問的問題:開通、token、固定 IP、查交易、退款、定期扣款、發票、撥款、付款通知與錯誤。
找不到答案時,可以用右上角的搜尋,或[聯絡我們](#%E8%81%AF%E7%B5%A1%E6%88%91%E5%80%91)。
## 開通
[Section titled “開通”](#開通)
**申請送出後要等多久?現在卡在哪一關?** 到 CRM「金流開通狀態」頁查看申請紀錄的狀態。各狀態的意思見[開通金流](/merchant/activate/#%E5%AF%A9%E6%A0%B8%E7%8B%80%E6%85%8B)。需要的時間依個案而定。
**顯示「文件未通過」怎麼辦?** 依通知補件後重新送出。有疑問請聯絡客服。
**申請表為什麼沒有超商代碼、ATM、LINE Pay 可以勾?** 這三種要請應援開通,申請表只有信用卡、Apple Pay 與應碰收。見[各付款方式怎麼開通](/merchant/activate/#%E5%90%84%E4%BB%98%E6%AC%BE%E6%96%B9%E5%BC%8F%E6%80%8E%E9%BA%BC%E9%96%8B%E9%80%9A)。
**「帳單顯示名稱」填錯了能改嗎?** 設定後無法更改,請在送出前確認。
**費率表顯示「未開通請聯絡應援」是什麼意思?** 這種付款方式還沒開通,請聯絡業務人員。
## API 串接
[Section titled “API 串接”](#api-串接)
**後台找不到「金流API」選單或「開發者」分頁?** 網域的 API 串接還沒開啟,或你的帳號沒有權限。見[交給工程師串接前的準備](/merchant/api-access/)。
**「產生 Token」按鈕是灰的?** 帳號權限不足,請管理員指派「金流串接管理員」角色。
**忘了複製 token,還看得到嗎?** 看不到,只能重新產生。重新產生會讓舊的立刻失效。
**測試環境和正式環境的 token 可以共用嗎?** 不行,要分別在各自的 CRM 產生。
**呼叫 API 一直回 401?** token 被重新產生過、用錯環境,或 header 格式錯誤。見[驗證與 token](/developers/authentication/#%E6%94%B6%E5%88%B0-401-a0001)。
**測試環境都正常,上線就回 403?** 正式環境的 IP 還沒綁定。見[固定 IP 白名單](/developers/ip-allowlist/)。
**付款通知網址可以在 API 請求裡帶嗎?** 不行,只能在 CRM 設定。請求裡多帶的欄位會被忽略。
**有沒有 Postman 可以用?** 可以匯入本站的 [OpenAPI 定義檔](/openapi/payment-api.json)。舊的 Postman 文件已不再更新。
## 付款與結帳頁
[Section titled “付款與結帳頁”](#付款與結帳頁)
**可以只開超商代碼、不要信用卡嗎?** 不行,信用卡一定會出現。見[付款方式](/developers/payment-methods/)。
**結帳連結可以先產生好,用 email 寄給消費者嗎?** 不行,結帳頁從建立起 5 分鐘內有效。請在消費者按下付款時才建立。
**消費者付款成功了,卻沒有回到我的網站?** 消費者可能付完就關掉視窗。請以付款通知與查詢結果為準,不要只靠導回。
**支援 Google Pay 或信用卡分期嗎?** 目前不支援。
**可以收港幣或其他外幣嗎?** Payment API 目前只支援新台幣。
## 查交易
[Section titled “查交易”](#查交易)
**API 交易在 CRM 哪裡找?** 「金流管理」→「金流列表」,類型是「現金購買」。可以用金流編號、訂單編號、姓名、Email、電話搜尋。
**為什麼只看到最近三個月?** 這是預設的日期範圍,可以自己調整。
**匯出的檔案在哪裡?** 到「下載管理」,完成後 1 小時內可以下載。
**「尚未付款」「已入帳」「撥款後退款」「已失效」是什麼意思?** 見[交易狀態](/merchant/transactions/#%E4%BA%A4%E6%98%93%E7%8B%80%E6%85%8B)。
## 退款
[Section titled “退款”](#退款)
**為什麼這筆交易沒有「退款」按鈕?** 交易不是「付款成功」或「已入帳」、已經退過一次,或你沒有退款權限。
**可以分兩次退嗎?** 不行,每筆交易只能退一次。
**超商付款的退款為什麼要填銀行帳戶?多久會退?** 超商代碼、ATM 的款項要用匯款退回。退款作業需要 3 到 7 個工作天。
**已經撥款的交易退款,錢從哪裡扣?** 從下次撥款扣除退款金額與退款手續費。
**出現「此筆金流缺少開立折讓所需的商品明細」?** 這筆交易有開發票但缺少商品明細,無法在 CRM 退款,請聯絡客服。
**用 API 退款收到 500 `Current charged amount too low`?** 你的網域尚未撥款的已收款金額低於 200 元,暫時無法退款,請聯絡應援。
## 定期扣款
[Section titled “定期扣款”](#定期扣款)
**怎麼取消某位消費者的定期扣款?取消後能恢復嗎?** 在 CRM「定期購買」按「終止定期購買」,或用 [API 取消](/api/cancel-subscription/)。取消後不能恢復。
**扣款失敗會自動重扣嗎?** 預設不會,而且一期失敗整個定期定額就停止。請在 CRM 開啟「定期交易重試機制」。見[扣款失敗](/developers/subscriptions/#%E6%89%A3%E6%AC%BE%E5%A4%B1%E6%95%97)。
**消費者要換信用卡,有頁面可以給他嗎?** Payment API 的定期購買沒有換卡頁面。常見做法是取消後請消費者重新訂閱。
**金流列表出現 1 元的「綁卡試刷」是什麼?** 綁卡時用來驗證卡片的 1 元授權,會立即退回。
## 發票
[Section titled “發票”](#發票)
**API 交易會自動開發票嗎?** 開通「應援代開電子發票」後,付款成功就會自動開立。見[電子發票](/merchant/invoices/)。
**發票開錯了怎麼作廢?** 後台沒有作廢功能,請聯絡客服。
**發票品名後面的「(受託代銷)」是什麼?** 由應援代開發票時會加上這幾個字。想用自己公司名義開立,請聯絡應援。
## 撥款
[Section titled “撥款”](#撥款)
**撥款頻率怎麼改?什麼時候生效?** 「總設定」→「金流設定」→「撥款方式設定」,從下一次撥款生效。
**為什麼 LINE Pay、應碰收 TWQR、藍新的錢不在撥款裡?** 這些款項由該機構直接撥給你。
**撥款帳戶要改?** 寄信到 。
## 付款通知
[Section titled “付款通知”](#付款通知)
**付款通知沒收到?** 應援最多送 3 次,都失敗就不會再送,也不能手動重送。請用訂單編號查詢補救。見[沒收到通知時](/developers/webhooks/#%E6%B2%92%E6%94%B6%E5%88%B0%E9%80%9A%E7%9F%A5%E6%99%82)。
**付款通知有簽章嗎?** 沒有。收到後一定要用查詢 API 回查。
**退款、取消定期扣款有通知嗎?** 要請應援開啟「Webhook 新版事件」才會送。
## WooCommerce
[Section titled “WooCommerce”](#woocommerce)
**Secret Key 要填哪一個?** 「OenPay Embed」分頁的 Secret Key,不是「開發者」分頁的存取 Token。見 [WooCommerce 外掛](/merchant/woocommerce/)。
**Webhook Secret 去哪裡拿?** 留空,存檔時外掛會自動取得。
## 聯絡我們
[Section titled “聯絡我們”](#聯絡我們)
* 常見問題:[www.oen.tw/faq](https://www.oen.tw/faq)
* 商家客服(LINE):[lin.ee/zgKzwaZ](https://lin.ee/zgKzwaZ),也可以從 CRM 右上角選單的「聯絡客服」進入
* 撥款與帳務:
聯絡時請提供網域名稱,以及交易編號或訂單編號。**請不要提供 token 或 Secret Key。**
# 名詞對照
> 用白話解釋文件裡常出現的名詞:結帳頁、付款通知、token、3D 驗證、定期定額、交易編號、撥款、折讓等。
依主題排列。每個名詞後面附上對應的說明頁。
## 帳號與串接
[Section titled “帳號與串接”](#帳號與串接)
| 名詞 | 白話解釋 |
| ------------------- | ------------------------------------------------------------------------------ |
| CRM | 應援的商家管理後台,網址是 `https://{網域名稱}.oen.tw/crm`。 |
| 網域名稱 | 你的應援頁子網域,例如 `ming.oen.tw` 的 `ming`。API 裡叫 `merchantId`,WooCommerce 外掛裡叫「商店代碼」。 |
| API 串接(開發者模式) | 讓你的系統可以呼叫 Payment API 的開關,由應援開啟。 |
| API token(存取 Token) | 你的系統呼叫 API 時用來證明身分的一串密碼,在 CRM 產生。見[驗證與 token](/developers/authentication/)。 |
| 測試環境、正式環境 | 測試環境不會真的扣款,用來開發與測試;正式環境才是真的收款。兩邊的帳號與 token 分開。 |
| 固定 IP 白名單 | 正式環境只接受事先登記過的伺服器 IP 呼叫。見[固定 IP 白名單](/developers/ip-allowlist/)。 |
## 付款
[Section titled “付款”](#付款)
| 名詞 | 白話解釋 |
| ------------- | ---------------------------------------------------- |
| 結帳頁 | 應援提供的付款頁面,消費者在這裡輸入信用卡或選擇付款方式。 |
| 3D 驗證 | 刷卡時跳到發卡銀行頁面,用簡訊驗證碼等方式確認是持卡人本人。 |
| 付款通知(webhook) | 付款結果確定時,應援自動送到你系統的訊息。見[付款通知](/developers/webhooks/)。 |
| 回查 | 收到付款通知後,再呼叫查詢 API 確認結果。因為付款通知沒有簽章,一定要回查。 |
| 超商代碼 | 消費者拿代碼到超商繳費的付款方式。 |
| ATM 虛擬帳號 | 每筆交易產生一個專屬帳號,消費者轉帳到這個帳號完成付款。 |
| 授權碼 | 刷卡成功時發卡銀行給的號碼,對帳時會用到。 |
## 交易與編號
[Section titled “交易與編號”](#交易與編號)
| 名詞 | 白話解釋 |
| ------------- | ------------------------------------------------------------ |
| 交易編號 | `P` 開頭、17 字元的編號,例如 `P20260928AB12CD34`。CRM 的「金流編號」就是它,退款也用它。 |
| 交易 id | 27 字元的英數字串。用來組結帳頁網址與查詢交易。 |
| 訂單編號(orderId) | 你自己系統的訂單編號,建立交易時帶給應援,之後可以用它查詢。 |
| 定期定額編號 | `S` 開頭、17 字元的編號,查詢與取消定期定額用。 |
| 現金購買 | API 交易在 CRM 金流列表顯示的類型名稱。 |
## 定期扣款與存卡
[Section titled “定期扣款與存卡”](#定期扣款與存卡)
| 名詞 | 白話解釋 |
| ---------- | ------------------------------------------------------------------------- |
| 定期定額(定期購買) | 消費者同意一次,之後由應援依週期自動扣固定金額。CRM 裡叫「定期購買」。見[定期定額](/developers/subscriptions/)。 |
| 預約定期定額 | 可以指定從哪一天開始扣、每幾個月扣一次的定期定額。 |
| 重扣 | 定期扣款失敗後,隔天再試一次。預設關閉,要在 CRM 開啟。 |
| 存卡、綁卡 | 消費者先登記信用卡,之後由商家決定何時扣款。 |
| token(卡片) | 存卡後得到的代號,用來代替卡號扣款。跟「API token」是不同的東西。 |
## 退款與撥款
[Section titled “退款與撥款”](#退款與撥款)
| 名詞 | 白話解釋 |
| --------------- | --------------------------------- |
| 部分退款 | 只退一部分金額。每筆交易只能退一次,所以部分退款後就不能再退。 |
| 退款帳戶(remitInfo) | 超商代碼、ATM 付款的交易退款時,要退到消費者的哪個銀行帳戶。 |
| 折讓 | 已開發票的交易退款時,開立的電子發票折讓單,系統會自動處理。 |
| 撥款 | 應援把代收的款項匯給商家。頻率可以在 CRM 設定。 |
| 撥款後退款 | 款項已經撥給商家後才退款,退款金額會從下次撥款扣除。 |
| 帳單顯示名稱 | 出現在消費者信用卡帳單上的商家名稱,申請金流時設定,之後不能更改。 |
# 快速開始
> 在測試環境建立第一筆結帳、把消費者導到應援結帳頁、收到付款通知並回查結果。
這一頁帶你在測試環境走完一筆單次付款:建立結帳 → 消費者付款 → 收到付款通知 → 回查確認。
## 開始前
[Section titled “開始前”](#開始前)
* **網域名稱**:例如應援頁是 `ming.oen.tw`,網域名稱就是 `ming`。以下範例都用 `ming`,請換成你的。
* **測試環境的 token**:在測試環境 CRM(`https://{網域名稱}.testing.oen.tw/crm`)的「總設定」→「開發者」→「應援金流設定」產生。還沒有測試環境帳號請請商家聯絡應援。詳見[交給工程師串接前的準備](/merchant/api-access/)。
* **能接收 HTTPS 的網址**:付款通知會送到這裡。本機開發可以用 tunnel 工具取得公開的 https 網址,再填到 CRM 的「交易資料回傳網址位置」。
注意
API 只能從你的伺服器呼叫。token 就像密碼,請放在伺服器的環境變數,不要放進前端程式或版本控制。
## 1. 設定 token
[Section titled “1. 設定 token”](#1-設定-token)
```bash
export OEN_API_TOKEN="貼上測試環境的 token"
export OEN_MERCHANT_ID="ming"
```
## 2. 建立結帳
[Section titled “2. 建立結帳”](#2-建立結帳)
在消費者按下「付款」時,由你的伺服器呼叫 `POST /checkout`。三個重點:
* `productDetails` **必填**,各品項「數量 × 單價」的合計必須等於 `amount`。
* 回應不會給結帳頁網址,要用 `data.id` 自己組:`https://{網域名稱}.testing.oen.tw/checkout/{id}`(正式環境沒有 `.testing`)。
* 結帳頁從建立起 **5 分鐘內**有效,請建立後立刻把消費者導過去。
- cURL
```bash
curl -X POST "https://payment-api.testing.oen.tw/checkout" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"amount": 1200,
"orderId": "A20260928001",
"successUrl": "https://shop.example.com/payment/success?order=A20260928001",
"failureUrl": "https://shop.example.com/payment/failure/A20260928001",
"productDetails": [
{ "productionCode": "SKU-001", "description": "手沖咖啡豆 200g", "quantity": 2, "unit": "包", "unitPrice": 600 }
]
}'
```
- Node.js
```js
const API = "https://payment-api.testing.oen.tw";
const CHECKOUT_HOST = `https://${process.env.OEN_MERCHANT_ID}.testing.oen.tw`;
export async function createCheckout(order) {
const res = await fetch(`${API}/checkout`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
merchantId: process.env.OEN_MERCHANT_ID,
amount: order.total, // 必須等於下面品項的合計
orderId: order.id,
successUrl: `https://shop.example.com/payment/success?order=${order.id}`,
failureUrl: `https://shop.example.com/payment/failure/${order.id}`,
productDetails: order.items.map((item) => ({
productionCode: item.sku,
description: item.name,
quantity: item.quantity,
unit: item.unit,
unitPrice: item.price,
})),
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
// 把 id 與 transactionHid 存進你的訂單,之後回查與退款會用到
await saveOenTransaction(order.id, result.data.id, result.data.transactionHid);
return `${CHECKOUT_HOST}/checkout/${result.data.id}`;
}
```
- PHP
```php
true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => $merchantId,
'amount' => $order['total'], // 必須等於品項合計
'orderId' => $order['id'],
'successUrl' => 'https://shop.example.com/payment/success?order=' . $order['id'],
'failureUrl' => 'https://shop.example.com/payment/failure/' . $order['id'],
'productDetails' => array_map(fn ($item) => [
'productionCode' => $item['sku'],
'description' => $item['name'],
'quantity' => $item['quantity'],
'unit' => $item['unit'],
'unitPrice' => $item['price'],
], $order['items']),
]),
]);
$result = json_decode(curl_exec($ch), true);
if (($result['code'] ?? '') !== 'S0000') {
throw new RuntimeException(($result['code'] ?? 'HTTP') . ' ' . ($result['message'] ?? ''));
}
// 把 id 與 transactionHid 存進你的訂單
saveOenTransaction($order['id'], $result['data']['id'], $result['data']['transactionHid']);
return "https://{$merchantId}.testing.oen.tw/checkout/{$result['data']['id']}";
}
header('Location: ' . createCheckout($order));
```
- Python
```python
import os
import requests
API = "https://payment-api.testing.oen.tw"
MERCHANT_ID = os.environ["OEN_MERCHANT_ID"]
def create_checkout(order: dict) -> str:
res = requests.post(
f"{API}/checkout",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": MERCHANT_ID,
"amount": order["total"], # 必須等於品項合計
"orderId": order["id"],
"successUrl": f"https://shop.example.com/payment/success?order={order['id']}",
"failureUrl": f"https://shop.example.com/payment/failure/{order['id']}",
"productDetails": [
{
"productionCode": item["sku"],
"description": item["name"],
"quantity": item["quantity"],
"unit": item["unit"],
"unitPrice": item["price"],
}
for item in order["items"]
],
},
timeout=30,
)
result = res.json()
if result.get("code") != "S0000":
raise RuntimeError(f"{result.get('code')} {result.get('message')}")
# 把 id 與 transactionHid 存進你的訂單
save_oen_transaction(order["id"], result["data"]["id"], result["data"]["transactionHid"])
return f"https://{MERCHANT_ID}.testing.oen.tw/checkout/{result['data']['id']}"
```
回應:
```json
{
"code": "S0000",
"data": { "id": "2HhndgEquCbDzC5OyVxWSGZmd2l", "transactionHid": "P20260928AB12CD34" },
"message": ""
}
```
* `id`:組結帳頁網址,也是之後付款通知與回查用的 id。
* `transactionHid`:`P` 開頭的交易編號,退款與 CRM 搜尋用這個。
## 3. 把消費者導到結帳頁
[Section titled “3. 把消費者導到結帳頁”](#3-把消費者導到結帳頁)
把消費者導到 `https://ming.testing.oen.tw/checkout/2HhndgEquCbDzC5OyVxWSGZmd2l`。消費者付款後:
* 成功時回到 `successUrl`,**網址不會帶任何參數**,所以範例把訂單編號放在自己的網址裡。
* 失敗時回到 `failureUrl`,網址加上 `payment_error`,例如 `?payment_error=T0004`。
不要只靠導回來判斷付款成功
消費者可能付完款就關掉視窗,永遠不會回到你的頁面;`successUrl` 也可以被任何人直接打開。**出貨一律以付款通知與回查結果為準。**
## 4. 接收付款通知並回查
[Section titled “4. 接收付款通知並回查”](#4-接收付款通知並回查)
付款完成後,應援會把結果 `POST` 到你在 CRM 設定的網址。**付款通知沒有簽章**,任何人都能偽造,所以收到後一定要用 `id` 呼叫 `GET /transactions/{id}` 回查,以查到的結果為準。
* cURL
```bash
# 用付款通知裡的 id 回查
curl "https://payment-api.testing.oen.tw/transactions/2HhndgEquCbDzC5OyVxWSGZmd2l" \
-H "Authorization: Bearer $OEN_API_TOKEN"
```
* Node.js
```js
import express from "express";
const app = express();
const PAID = new Set(["charged", "claimed"]);
app.post("/webhooks/oen", express.json(), (req, res) => {
// 先回 200:應援每次最多等 10 秒,逾時會重送
res.sendStatus(200);
const event = req.body;
if (event.purpose !== "charge") return; // token 等其他通知另外處理
handleCharge(event.id).catch((err) => console.error("OEN webhook", err));
});
async function handleCharge(id) {
// 不相信通知內容,一律回查
const res = await fetch(`https://payment-api.testing.oen.tw/transactions/${encodeURIComponent(id)}`, {
headers: { Authorization: `Bearer ${process.env.OEN_API_TOKEN}` },
});
const result = await res.json();
if (result.code !== "S0000") throw new Error(`${result.code} ${result.message}`);
const txn = result.data;
// 確認是你建立的那一筆:比對訂單編號與金額
const order = await findOrderByOenId(txn.transactionId);
if (!order || order.total !== txn.amount) return;
if (PAID.has(txn.status)) await markOrderPaid(order.id, txn); // 同一筆重複呼叫也要安全
else if (txn.status === "failed") await markOrderFailed(order.id, txn);
// charging:超商、ATM 等待繳費,等下一則通知
}
app.listen(3000);
```
* PHP
```php
true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('OEN_API_TOKEN')],
]);
$result = json_decode(curl_exec($ch), true);
if (($result['code'] ?? '') !== 'S0000') exit;
$txn = $result['data'];
$order = findOrderByOenId($txn['transactionId']);
if (!$order || $order['total'] !== $txn['amount']) exit;
if (in_array($txn['status'], ['charged', 'claimed'], true)) {
markOrderPaid($order['id'], $txn); // 同一筆重複呼叫也要安全
} elseif ($txn['status'] === 'failed') {
markOrderFailed($order['id'], $txn);
}
```
* Python
```python
import os
import threading
import requests
from flask import Flask, request
app = Flask(__name__)
PAID = {"charged", "claimed"}
def handle_charge(transaction_id: str) -> None:
# 不相信通知內容,一律回查
res = requests.get(
f"https://payment-api.testing.oen.tw/transactions/{transaction_id}",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
timeout=30,
)
result = res.json()
if result.get("code") != "S0000":
return
txn = result["data"]
order = find_order_by_oen_id(txn["transactionId"])
if not order or order["total"] != txn["amount"]:
return
if txn["status"] in PAID:
mark_order_paid(order["id"], txn) # 同一筆重複呼叫也要安全
elif txn["status"] == "failed":
mark_order_failed(order["id"], txn)
@app.post("/webhooks/oen")
def oen_webhook():
event = request.get_json(silent=True) or {}
if event.get("purpose") == "charge":
# 放到背景處理,先回 200:應援每次最多等 10 秒
threading.Thread(target=handle_charge, args=(event["id"],)).start()
return "", 200
```
## 5. 付款測試
[Section titled “5. 付款測試”](#5-付款測試)
打開結帳頁,用測試卡付款,確認:
* 你的伺服器收到付款通知,回查的 `status` 是 `charged`。
* 訂單狀態有更新。
* 在 CRM「金流管理」→「金流列表」看得到這筆交易(類型是「現金購買」)。
測試卡與測試環境的注意事項見[環境與測試](/developers/environments/)。
## 接下來
[Section titled “接下來”](#接下來)
* [單次付款](/developers/one-time/):付款方式、發票、結帳頁逾時與重新付款。
* [付款通知](/developers/webhooks/):重送規則、同一筆的多則通知、沒收到通知時怎麼補。
* [錯誤處理](/developers/errors/):哪些錯誤可以重試,付款結果不明時怎麼辦。
* 上線前走一遍[上線檢查清單](/developers/go-live/),並申請[固定 IP 白名單](/developers/ip-allowlist/)。
# API 參考
> Payment API 的共通規格:網址、驗證、回應格式、錯誤格式、分頁、時間與 ID,以及所有端點一覽。
這裡是 Payment API 每一支端點的完整規格。第一次串接請先看[快速開始](/developers/quickstart/)。
所有欄位、限制與錯誤碼都依正式環境 v10.3.1.1 的程式核對。也可以下載 [OpenAPI 定義檔](/openapi/payment-api.json),匯入 Postman 等工具。
## 網址
[Section titled “網址”](#網址)
| 環境 | API | 結帳頁 |
| -- | ------------------------------------ | ------------------------------------- |
| 正式 | `https://payment-api.oen.tw` | `https://{merchantId}.oen.tw` |
| 測試 | `https://payment-api.testing.oen.tw` | `https://{merchantId}.testing.oen.tw` |
正式環境要先綁定固定 IP
正式環境只接受已綁定的 IP,其他來源一律回 HTTP 403。測試環境沒有這個限制。見[固定 IP 白名單](/developers/ip-allowlist/)。
## 驗證
[Section titled “驗證”](#驗證)
每個請求都帶:
```http
Authorization: Bearer
Content-Type: application/json
```
* token 在 CRM 產生,測試與正式環境各一把,不能互用。見[驗證與 token](/developers/authentication/)。
* `Bearer` 要照這個大小寫,中間只有一個空白。
* 有 body 的請求要帶 `merchantId`,而且必須是 token 所屬的網域。
* **只能從伺服器呼叫**。API 不支援瀏覽器跨網域請求,token 也不能放在前端。
## 回應格式
[Section titled “回應格式”](#回應格式)
成功時 HTTP 200:
```json
{ "code": "S0000", "data": { }, "message": "" }
```
失敗時:
```json
{ "code": "V0001", "data": {}, "message": "must have required property 'orderId'" }
```
* 程式判斷請看 `code`,`message` 的文字可能調整。
* 付款結果不明時是 HTTP 409,另外帶 `"retryable": false`,見 [C026](/api/error-codes/)。
* 個人身分商家被風控拒絕時(`K0001`),另外帶 `detail`。
* 沒有值的欄位不會出現在回應裡。
* 找不到路徑時回 404、不支援的 HTTP method 回 405,body 是純文字,不是上面的 JSON 格式。
* 被正式環境的 IP 白名單擋下時回 403,body 只有 `message` 欄位(通常是 `Forbidden`),沒有 `code`。
## 欄位驗證
[Section titled “欄位驗證”](#欄位驗證)
* body 必須是合法的 JSON,否則回 500 `F0001`。
* 規格裡沒有的欄位會被忽略,不會報錯。所以欄位名稱拼錯時不會有任何提示,請對照各端點的欄位表。例如 Payment API 沒有 `webhookUrl`、`cancelUrl` 這類欄位,付款通知網址在 CRM 設定。
* 驗證失敗回 400 `V0001`,`message` 會列出所有不符合的規則,但不一定有欄位名稱。
## 分頁
[Section titled “分頁”](#分頁)
[查詢交易列表](/api/list-transactions/)與[查詢商店定期購列表](/api/list-subscriptions/)每頁 50 筆。回應的 `page` 是下一頁的分頁標記,原封不動放進下一次請求的 `?page=`;沒有下一頁時是 `null`。標記無效時回 500 `F0001`。
## 時間
[Section titled “時間”](#時間)
* 回應的時間都是 UTC 的 ISO 8601 字串,例如 `2026-09-28T02:40:25.502Z`。
* 你送出的日期(`startDate`、`expectedPayoutDate`)格式是 `yyyy/MM/dd`,以台北日期解讀。
* 定期定額每天台北時間上午 9 點扣款。
## ID
[Section titled “ID”](#id)
| 名稱 | 格式 | 用在 |
| ----------------------- | --------------------------------------------- | ---------------------------------------------------------------- |
| 交易 `id`/`transactionId` | 27 字元英數 | 組結帳頁網址、付款通知、[查詢交易明細](/api/get-transaction/) |
| 交易編號 `transactionHid` | `P` + 日期 + 8 碼,共 17 字元,例如 `P20260928AB12CD34` | [退款](/api/refund/)、對帳、CRM 搜尋 |
| 定期定額編號 | `S` + 日期 + 8 碼,共 17 字元 | [查詢](/api/get-subscription/)與[取消定期定額](/api/cancel-subscription/) |
| `orderId` | 你自己的訂單編號 | [用訂單編號查詢](/api/list-order-transactions/) |
## 冪等性與頻率限制
[Section titled “冪等性與頻率限制”](#冪等性與頻率限制)
* Payment API **沒有** `Idempotency-Key`。同樣的請求送兩次,就會建立兩筆交易。逾時或收到 5xx 時,先查詢再決定要不要重送,見[錯誤處理](/developers/errors/)。
* 目前沒有對個別商家限制請求頻率,但請不要高頻率輪詢;日後可能開始限制。
## 端點一覽
[Section titled “端點一覽”](#端點一覽)
| Method | Path | 說明 |
| ------ | ---------------------------------------------------------------- | -------------- |
| POST | [`/checkout`](/api/checkout/) | 建立單次付款 |
| POST | [`/checkout-subscription`](/api/checkout-subscription/) | 建立定期定額 |
| POST | [`/checkout-schedule`](/api/checkout-schedule/) | 建立預約定期定額 |
| POST | [`/checkout-token`](/api/checkout-token/) | 建立綁卡頁 |
| POST | [`/token/transactions`](/api/token-transactions/) | 用 token 扣款 |
| POST | [`/token/subscriptions`](/api/token-subscriptions/) | 用 token 建立定期定額 |
| GET | [`/transactions/{id}`](/api/get-transaction/) | 查詢交易明細 |
| GET | [`/transactions`](/api/list-transactions/) | 查詢交易列表 |
| GET | [`/order/{orderId}/transactions`](/api/list-order-transactions/) | 用訂單編號查詢交易 |
| POST | [`/refunds/{transactionHid}`](/api/refund/) | 退款 |
| GET | [`/subscriptions/{id}`](/api/get-subscription/) | 查詢定期定額明細 |
| PUT | [`/subscriptions/{subscriptionHid}`](/api/cancel-subscription/) | 取消定期定額 |
| GET | [`/subscriptions`](/api/list-subscriptions/) | 查詢商店定期購訂單列表 |
需要傳送完整卡號的 API 必須符合 PCI DSS 規範,不在本站公開。若你的情境確實需要,請聯絡業務人員個別討論。
# 取消定期定額
> 取消一筆定期定額,之後不再扣款。
PUT `/subscriptions/:subscriptionHid`
* 正式環境:`https://payment-api.oen.tw/subscriptions/:subscriptionHid`
* 測試環境:`https://payment-api.testing.oen.tw/subscriptions/:subscriptionHid`
取消一筆 Payment API 定期定額,之後不會再扣款。只能取消進行中、已排程、扣款失敗待重扣的定期定額。**請一定要送 body**,至少帶 `merchantId`。
Header 帶 `Authorization: Bearer ` 與 `Content-Type: application/json` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### 路徑參數
[Section titled “路徑參數”](#路徑參數)
| 欄位 | 型別 | 必填 | 說明 |
| ----------------- | -------- | -- | ------------------------------ |
| `subscriptionHid` | `string` | 必填 | `S` 開頭的定期定額編號(17 字元)。不接受內部 id。 |
### Body 欄位
[Section titled “Body 欄位”](#body-欄位)
| 欄位 | 型別 | 必填 | 說明 |
| ------------ | -------- | -- | ------------------------------------------------------------------------------------------ |
| `merchantId` | `string` | 必填 | 你的網域名稱。例如應援頁是 `https://ming.oen.tw`,就填 `ming`。必須與 token 所屬的網域相同,不同會回 400 `V0001`。範例:`ming` |
| `reason` | `string` | 選填 | 取消原因。 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X PUT "https://payment-api.testing.oen.tw/subscriptions/S20260928EF56GH78" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"reason": "會員申請取消"
}'
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/subscriptions/S20260928EF56GH78", {
method: "PUT",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"merchantId": "ming",
"reason": "會員申請取消"
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'PUT',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => 'ming',
'reason' => '會員申請取消',
]),
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"PUT",
"https://payment-api.testing.oen.tw/subscriptions/S20260928EF56GH78",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": "ming",
"reason": "會員申請取消",
},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | --------------- |
| `200` | 取消成功,回傳更新後的定期定額 |
| `400` | 找不到,或目前狀態不能取消 |
| `401` | token 錯誤 |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"id": "S20260928EF56GH78",
"status": "cancelled",
"period": 1,
"paymentInterval": 1,
"startedAt": "2026-09-28T02:31:46.640Z",
"cancelledAt": "2026-09-28T02:35:00.000Z",
"reason": "會員申請取消",
"createdAt": "2026-09-28T02:31:46.640Z"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | -------------------------------------------- |
| `V0001` | 400 | 找不到定期定額、不屬於你的網域,或用了內部 id |
| `V0002` | 400 | 目前狀態不能取消(例如消費者還沒完成結帳、已取消、已完成、已停止),或取消當下剛好在扣款 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
消費者提出取消時就應該取消,見[定期定額](/developers/subscriptions/)。
# 建立單次付款
> 建立單次付款的結帳頁,把消費者導到應援結帳頁付款。
POST `/checkout`
* 正式環境:`https://payment-api.oen.tw/checkout`
* 測試環境:`https://payment-api.testing.oen.tw/checkout`
建立一筆單次付款,回傳交易的 `id`。把消費者導到 `https://{merchantId}.oen.tw/checkout/{id}`(測試環境是 `{merchantId}.testing.oen.tw`)付款。**結帳頁從呼叫這支 API 起 5 分鐘內有效**,請在消費者按下付款時才建立。
Header 帶 `Authorization: Bearer ` 與 `Content-Type: application/json` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### Body 欄位
[Section titled “Body 欄位”](#body-欄位)
| 欄位 | 型別 | 必填 | 說明 |
| ------------------------------------------------ | ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `merchantId` | `string` | 必填 | 你的網域名稱。例如應援頁是 `https://ming.oen.tw`,就填 `ming`。必須與 token 所屬的網域相同,不同會回 400 `V0001`。範例:`ming` |
| `amount` | `integer` | 必填 | 金額(新台幣元)。必須等於 `productDetails` 各品項「數量 × 單價」的合計。限制:1 以上的整數範例:`1200` |
| `currency` | `string` | 選填 | 幣別。目前只支援新台幣。可用值:`TWD`預設:`TWD` |
| `orderId` | `string` | 必填 | 你的訂單編號。之後可以用[用訂單編號查詢](/api/list-order-transactions/)找到這筆訂單的所有交易。限制:不檢查重複。同一個 `orderId` 可以建立多筆交易範例:`A20260928001` |
| `successUrl` | `string (uri) \| null` | 必填 | 付款成功後把消費者導回的網址。**導回時不帶任何參數**,請在網址裡放自己的訂單編號,例如 `https://shop.example.com/thanks?order=A001`。導回不代表付款成功,請以付款通知或查詢結果為準。限制:要寫完整網址(含 `https://`);可以是 `null`,但欄位一定要有 |
| `failureUrl` | `string (uri) \| null` | 必填 | 付款失敗或逾時後導回的網址,會加上 `payment_error` 參數,可能的值見[錯誤碼一覽](/api/error-codes/#結帳頁導回的-payment_error)。限制:同 `successUrl`。建議不要自帶 `?` 參數與 `#` 片段 |
| `productDetails` | `array` | 必填 | 商品明細,用來開立電子發票與顯示在 CRM。**所有品項「數量 × 單價」的合計必須等於 `amount`**,否則回 400 `V0001`(`PRODUCT_AMOUNT_NOT_MATCH`)。 |
| `productionCode`productDetails\[].productionCode | `string` | 必填 | 商品代碼 |
| `description`productDetails\[].description | `string` | 必填 | 商品名稱。第一個品項的名稱也會當成交易的商品描述 |
| `quantity`productDetails\[].quantity | `integer` | 必填 | 數量限制:1 以上的整數 |
| `unit`productDetails\[].unit | `string` | 必填 | 單位,例如「個」「份」 |
| `unitPrice`productDetails\[].unitPrice | `integer` | 必填 | 單價(新台幣元) |
| `allowedPaymentMethods` | `array` | 選填 | 結帳頁可以選的付款方式。**信用卡一定會出現**,這裡是「加開」其他方式:`['cvs']` 會顯示信用卡與超商。沒帶就只有信用卡。Apple Pay 不用指定,條件符合時自動出現。詳見[付款方式](/developers/payment-methods/)。可用值:`card`、`cvs`、`linePay`、`atm`範例:`["cvs"]` |
| `userId` | `string` | 選填 | 你系統裡的會員編號。 |
| `userName` | `string` | 條件必填 | 消費者姓名。網域有開通電子發票時必填。 |
| `userEmail` | `string (email)` | 條件必填 | 消費者 Email。網域有開通電子發票時必填,發票通知會寄到這裡。 |
| `invoiceInfo` | `object` | 選填 | 電子發票資訊。網域有開通電子發票時才有作用;沒帶時開立雲端發票。 |
| `invoiceType`invoiceInfo.invoiceType | `string` | 必填 | `cloud`:雲端發票;`company`:公司戶(打統編)可用值:`cloud`、`company` |
| `carrierType`invoiceInfo.carrierType | `string` | 雲端發票必填 | 載具類型:`3J0002` 手機條碼、`CQ0001` 自然人憑證、空字串為會員載具可用值:`3J0002`、`CQ0001`、`` |
| `carrierId`invoiceInfo.carrierId | `string` | 選填 | 載具號碼。手機條碼格式為 `/` 加 7 碼,會即時向財政部驗證;自然人憑證為 2 個英文字母加 14 碼數字 |
| `buyerIdentifier`invoiceInfo.buyerIdentifier | `string` | 公司戶必填 | 買受人統一編號,8 碼,會檢查檢查碼 |
| `buyerName`invoiceInfo.buyerName | `string` | 選填 | 買受人名稱 |
| `email`invoiceInfo.email | `string (email)` | 選填 | 發票通知 Email |
| `customId` | `string` | 選填 | 你自訂的資料,會原樣出現在付款通知與查詢結果中。 |
| `note` | `string` | 選填 | 備註。 |
| `use3d` | `boolean` | 選填 | 是否走 3D 驗證。網域被設定為一律 3D 時會強制開啟。預設:`false` |
| `expectedPayoutDate` | `string` | 選填 | 期望撥款日期(台北日期),會顯示在 CRM 金流明細。限制:`yyyy/MM/dd` |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X POST "https://payment-api.testing.oen.tw/checkout" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"amount": 1200,
"currency": "TWD",
"orderId": "A20260928001",
"successUrl": "https://shop.example.com/payment/success?order=A20260928001",
"failureUrl": "https://shop.example.com/payment/failure/A20260928001",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 2,
"unit": "包",
"unitPrice": 600
}
],
"allowedPaymentMethods": [
"cvs"
],
"customId": "cart-8812"
}'
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/checkout", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"merchantId": "ming",
"amount": 1200,
"currency": "TWD",
"orderId": "A20260928001",
"successUrl": "https://shop.example.com/payment/success?order=A20260928001",
"failureUrl": "https://shop.example.com/payment/failure/A20260928001",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 2,
"unit": "包",
"unitPrice": 600
}
],
"allowedPaymentMethods": [
"cvs"
],
"customId": "cart-8812"
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => 'ming',
'amount' => 1200,
'currency' => 'TWD',
'orderId' => 'A20260928001',
'successUrl' => 'https://shop.example.com/payment/success?order=A20260928001',
'failureUrl' => 'https://shop.example.com/payment/failure/A20260928001',
'userName' => '王小明',
'userEmail' => 'ming@example.com',
'productDetails' => [
[
'productionCode' => 'SKU-001',
'description' => '手沖咖啡豆 200g',
'quantity' => 2,
'unit' => '包',
'unitPrice' => 600,
],
],
'allowedPaymentMethods' => [
'cvs',
],
'customId' => 'cart-8812',
]),
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"POST",
"https://payment-api.testing.oen.tw/checkout",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": "ming",
"amount": 1200,
"currency": "TWD",
"orderId": "A20260928001",
"successUrl": "https://shop.example.com/payment/success?order=A20260928001",
"failureUrl": "https://shop.example.com/payment/failure/A20260928001",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 2,
"unit": "包",
"unitPrice": 600,
},
],
"allowedPaymentMethods": [
"cvs",
],
"customId": "cart-8812",
},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ----------------------------------------------------- |
| `200` | 建立成功。`data.id` 用來組結帳頁網址,`data.transactionHid` 用來退款與對帳 |
| `400` | 參數錯誤、金流未開通或風控拒絕,見下方錯誤表 |
| `401` | token 錯誤,回 `A0001` |
| `403` | 正式環境的 IP 還沒綁定,見[固定 IP 白名單](/developers/ip-allowlist/) |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| ---------------- | -------- | --- | ------------------------------------- |
| `id` | `string` | 一定有 | 交易的內部 id(27 字元),組結帳頁網址用,也是付款通知裡的 `id` |
| `transactionHid` | `string` | 一定有 | 交易編號,`P` 開頭共 17 字元。退款要用這個 |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"id": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"transactionHid": "P20260928AB12CD34"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `V0001` | 400 | 欄位不符規則;`merchantId` 與 token 不符;品項合計不等於金額(`PRODUCT_AMOUNT_NOT_MATCH`);開通電子發票但缺姓名或 Email(`USER_NAME_AND_EMAIL_REQUIRED`);發票資訊錯誤;指定 `linePay` 但 LINE Pay 未開通(`LINE_PAY_NOT_ACTIVE`) |
| `V0002` | 400 | 網域的金流服務尚未開通(`PAYMENT_SERVICE_NOT_ACTIVATE`) |
| `K0001` | 400 | 個人身分商家的交易金額超過風控門檻 |
| `A0001` | 401 | token 錯誤或已被重新產生 |
| `F0001` | 500 | 系統錯誤,或 body 不是合法的 JSON |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
流程與注意事項見[單次付款](/developers/one-time/)。付款方式的組合見[付款方式](/developers/payment-methods/)。
# 建立預約定期定額
> 建立可以指定首期扣款日與扣款間隔的定期定額結帳頁。
POST `/checkout-schedule`
* 正式環境:`https://payment-api.oen.tw/checkout-schedule`
* 測試環境:`https://payment-api.testing.oen.tw/checkout-schedule`
建立可以指定首期扣款日與扣款間隔的定期定額,回傳 `id` 與定期定額編號 `subscriptionHid`。把消費者導到 `https://{merchantId}.oen.tw/checkout/schedule/{id}`。首期日是今天時,消費者付款當下扣第一期;是未來日期時,消費者只綁卡,到期由應援扣款。結帳頁 5 分鐘內有效。
Header 帶 `Authorization: Bearer ` 與 `Content-Type: application/json` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### Body 欄位
[Section titled “Body 欄位”](#body-欄位)
| 欄位 | 型別 | 必填 | 說明 |
| ------------------------------------------------ | ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `merchantId` | `string` | 必填 | 你的網域名稱。例如應援頁是 `https://ming.oen.tw`,就填 `ming`。必須與 token 所屬的網域相同,不同會回 400 `V0001`。範例:`ming` |
| `amount` | `integer` | 必填 | 每期金額(新台幣元)。必須等於 `productDetails` 的合計。限制:1 以上的整數範例:`1200` |
| `currency` | `string` | 選填 | 幣別。目前只支援新台幣。可用值:`TWD`預設:`TWD` |
| `numberOfPeriods` | `integer` | 選填 | 總期數。不帶就是不限期,直到取消。限制:2 以上 |
| `paymentInterval` | `integer` | 選填 | 每幾個月扣款一次。限制:1 到 12預設:`1` |
| `startDate` | `string \| null` | 選填 | 首期扣款日(台北日期)。不帶就是今天。限制:`yyyy/MM/dd`;今天到 12 個月內範例:`2026/10/15` |
| `orderId` | `string` | 必填 | 你的訂單編號。之後可以用[用訂單編號查詢](/api/list-order-transactions/)找到這筆訂單的所有交易。限制:不檢查重複。同一個 `orderId` 可以建立多筆交易範例:`A20260928001` |
| `successUrl` | `string (uri) \| null` | 必填 | 付款成功後把消費者導回的網址。**導回時不帶任何參數**,請在網址裡放自己的訂單編號,例如 `https://shop.example.com/thanks?order=A001`。導回不代表付款成功,請以付款通知或查詢結果為準。限制:要寫完整網址(含 `https://`);可以是 `null`,但欄位一定要有 |
| `failureUrl` | `string (uri) \| null` | 必填 | 付款失敗或逾時後導回的網址,會加上 `payment_error` 參數,可能的值見[錯誤碼一覽](/api/error-codes/#結帳頁導回的-payment_error)。限制:同 `successUrl`。建議不要自帶 `?` 參數與 `#` 片段 |
| `productDetails` | `array` | 必填 | 商品明細,用來開立電子發票與顯示在 CRM。**所有品項「數量 × 單價」的合計必須等於 `amount`**,否則回 400 `V0001`(`PRODUCT_AMOUNT_NOT_MATCH`)。 |
| `productionCode`productDetails\[].productionCode | `string` | 必填 | 商品代碼 |
| `description`productDetails\[].description | `string` | 必填 | 商品名稱。第一個品項的名稱也會當成交易的商品描述 |
| `quantity`productDetails\[].quantity | `integer` | 必填 | 數量限制:1 以上的整數 |
| `unit`productDetails\[].unit | `string` | 必填 | 單位,例如「個」「份」 |
| `unitPrice`productDetails\[].unitPrice | `integer` | 必填 | 單價(新台幣元) |
| `userId` | `string` | 選填 | 你系統裡的會員編號。 |
| `userName` | `string` | 條件必填 | 消費者姓名。網域有開通電子發票時必填。 |
| `userEmail` | `string (email)` | 條件必填 | 消費者 Email。網域有開通電子發票時必填,發票通知會寄到這裡。 |
| `invoiceInfo` | `object` | 選填 | 電子發票資訊。網域有開通電子發票時才有作用;沒帶時開立雲端發票。 |
| `invoiceType`invoiceInfo.invoiceType | `string` | 必填 | `cloud`:雲端發票;`company`:公司戶(打統編)可用值:`cloud`、`company` |
| `carrierType`invoiceInfo.carrierType | `string` | 雲端發票必填 | 載具類型:`3J0002` 手機條碼、`CQ0001` 自然人憑證、空字串為會員載具可用值:`3J0002`、`CQ0001`、`` |
| `carrierId`invoiceInfo.carrierId | `string` | 選填 | 載具號碼。手機條碼格式為 `/` 加 7 碼,會即時向財政部驗證;自然人憑證為 2 個英文字母加 14 碼數字 |
| `buyerIdentifier`invoiceInfo.buyerIdentifier | `string` | 公司戶必填 | 買受人統一編號,8 碼,會檢查檢查碼 |
| `buyerName`invoiceInfo.buyerName | `string` | 選填 | 買受人名稱 |
| `email`invoiceInfo.email | `string (email)` | 選填 | 發票通知 Email |
| `customId` | `string` | 選填 | 你自訂的資料,會原樣出現在付款通知與查詢結果中。 |
| `note` | `string` | 選填 | 備註。 |
| `use3d` | `boolean` | 選填 | 是否走 3D 驗證。網域被設定為一律 3D 時會強制開啟。預設:`false` |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X POST "https://payment-api.testing.oen.tw/checkout-schedule" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"amount": 1500,
"numberOfPeriods": 4,
"paymentInterval": 3,
"startDate": "2026/10/15",
"orderId": "SUB20260928002",
"successUrl": "https://shop.example.com/subscribe/success?order=SUB20260928002",
"failureUrl": "https://shop.example.com/subscribe/failure/SUB20260928002",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "BOX-Q",
"description": "季訂閱禮盒",
"quantity": 1,
"unit": "盒",
"unitPrice": 1500
}
]
}'
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/checkout-schedule", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"merchantId": "ming",
"amount": 1500,
"numberOfPeriods": 4,
"paymentInterval": 3,
"startDate": "2026/10/15",
"orderId": "SUB20260928002",
"successUrl": "https://shop.example.com/subscribe/success?order=SUB20260928002",
"failureUrl": "https://shop.example.com/subscribe/failure/SUB20260928002",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "BOX-Q",
"description": "季訂閱禮盒",
"quantity": 1,
"unit": "盒",
"unitPrice": 1500
}
]
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => 'ming',
'amount' => 1500,
'numberOfPeriods' => 4,
'paymentInterval' => 3,
'startDate' => '2026/10/15',
'orderId' => 'SUB20260928002',
'successUrl' => 'https://shop.example.com/subscribe/success?order=SUB20260928002',
'failureUrl' => 'https://shop.example.com/subscribe/failure/SUB20260928002',
'userName' => '王小明',
'userEmail' => 'ming@example.com',
'productDetails' => [
[
'productionCode' => 'BOX-Q',
'description' => '季訂閱禮盒',
'quantity' => 1,
'unit' => '盒',
'unitPrice' => 1500,
],
],
]),
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"POST",
"https://payment-api.testing.oen.tw/checkout-schedule",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": "ming",
"amount": 1500,
"numberOfPeriods": 4,
"paymentInterval": 3,
"startDate": "2026/10/15",
"orderId": "SUB20260928002",
"successUrl": "https://shop.example.com/subscribe/success?order=SUB20260928002",
"failureUrl": "https://shop.example.com/subscribe/failure/SUB20260928002",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "BOX-Q",
"description": "季訂閱禮盒",
"quantity": 1,
"unit": "盒",
"unitPrice": 1500,
},
],
},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | -------- |
| `200` | 建立成功 |
| `400` | 參數錯誤 |
| `401` | token 錯誤 |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| ----------------- | -------- | --- | ---------------------------------- |
| `id` | `string` | 一定有 | 定期定額的內部 id,組結帳頁網址用 |
| `subscriptionHid` | `string` | 一定有 | 定期定額編號,`S` 開頭共 17 字元。查詢與取消定期定額都用這個 |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"id": "2HhnfZ1kqRmA7cX0TQwq8vBn3Ls",
"subscriptionHid": "S20260928EF56GH78"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | -------------------------------------------------------------------------------------------------------------- |
| `V0001` | 400 | 欄位不符規則;首期日早於今天(`START_DATE_MUST_GREATER_THAN_TODAY`)或超過 12 個月(`START_DATE_MUST_LESS_THAN_12_MONTHS`);品項合計不等於金額 |
| `K0001` | 400 | 個人身分商家的交易金額超過風控門檻 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
跟「建立定期定額」的差別見[定期定額](/developers/subscriptions/)。
# 建立定期定額
> 建立每月扣款的定期定額結帳頁。
POST `/checkout-subscription`
* 正式環境:`https://payment-api.oen.tw/checkout-subscription`
* 測試環境:`https://payment-api.testing.oen.tw/checkout-subscription`
建立每月扣款的定期定額,回傳交易的 `id`。把消費者導到 `https://{merchantId}.oen.tw/checkout/subscription/{id}`。消費者在結帳頁付款時扣第一期,之後每個月同一天由應援自動扣款。只支援信用卡。結帳頁 5 分鐘內有效。
Header 帶 `Authorization: Bearer ` 與 `Content-Type: application/json` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### Body 欄位
[Section titled “Body 欄位”](#body-欄位)
| 欄位 | 型別 | 必填 | 說明 |
| ------------------------------------------------ | ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `merchantId` | `string` | 必填 | 你的網域名稱。例如應援頁是 `https://ming.oen.tw`,就填 `ming`。必須與 token 所屬的網域相同,不同會回 400 `V0001`。範例:`ming` |
| `amount` | `integer` | 必填 | 每期金額(新台幣元)。必須等於 `productDetails` 的合計。限制:1 以上的整數範例:`1200` |
| `currency` | `string` | 選填 | 幣別。目前只支援新台幣。可用值:`TWD`預設:`TWD` |
| `numberOfPeriods` | `integer` | 選填 | 總期數。不帶就是不限期,直到取消。限制:2 以上 |
| `orderId` | `string` | 必填 | 你的訂單編號。之後可以用[用訂單編號查詢](/api/list-order-transactions/)找到這筆訂單的所有交易。限制:不檢查重複。同一個 `orderId` 可以建立多筆交易範例:`A20260928001` |
| `successUrl` | `string (uri) \| null` | 必填 | 付款成功後把消費者導回的網址。**導回時不帶任何參數**,請在網址裡放自己的訂單編號,例如 `https://shop.example.com/thanks?order=A001`。導回不代表付款成功,請以付款通知或查詢結果為準。限制:要寫完整網址(含 `https://`);可以是 `null`,但欄位一定要有 |
| `failureUrl` | `string (uri) \| null` | 必填 | 付款失敗或逾時後導回的網址,會加上 `payment_error` 參數,可能的值見[錯誤碼一覽](/api/error-codes/#結帳頁導回的-payment_error)。限制:同 `successUrl`。建議不要自帶 `?` 參數與 `#` 片段 |
| `productDetails` | `array` | 必填 | 商品明細,用來開立電子發票與顯示在 CRM。**所有品項「數量 × 單價」的合計必須等於 `amount`**,否則回 400 `V0001`(`PRODUCT_AMOUNT_NOT_MATCH`)。 |
| `productionCode`productDetails\[].productionCode | `string` | 必填 | 商品代碼 |
| `description`productDetails\[].description | `string` | 必填 | 商品名稱。第一個品項的名稱也會當成交易的商品描述 |
| `quantity`productDetails\[].quantity | `integer` | 必填 | 數量限制:1 以上的整數 |
| `unit`productDetails\[].unit | `string` | 必填 | 單位,例如「個」「份」 |
| `unitPrice`productDetails\[].unitPrice | `integer` | 必填 | 單價(新台幣元) |
| `userId` | `string` | 選填 | 你系統裡的會員編號。 |
| `userName` | `string` | 條件必填 | 消費者姓名。網域有開通電子發票時必填。 |
| `userEmail` | `string (email)` | 條件必填 | 消費者 Email。網域有開通電子發票時必填,發票通知會寄到這裡。 |
| `invoiceInfo` | `object` | 選填 | 電子發票資訊。網域有開通電子發票時才有作用;沒帶時開立雲端發票。 |
| `invoiceType`invoiceInfo.invoiceType | `string` | 必填 | `cloud`:雲端發票;`company`:公司戶(打統編)可用值:`cloud`、`company` |
| `carrierType`invoiceInfo.carrierType | `string` | 雲端發票必填 | 載具類型:`3J0002` 手機條碼、`CQ0001` 自然人憑證、空字串為會員載具可用值:`3J0002`、`CQ0001`、`` |
| `carrierId`invoiceInfo.carrierId | `string` | 選填 | 載具號碼。手機條碼格式為 `/` 加 7 碼,會即時向財政部驗證;自然人憑證為 2 個英文字母加 14 碼數字 |
| `buyerIdentifier`invoiceInfo.buyerIdentifier | `string` | 公司戶必填 | 買受人統一編號,8 碼,會檢查檢查碼 |
| `buyerName`invoiceInfo.buyerName | `string` | 選填 | 買受人名稱 |
| `email`invoiceInfo.email | `string (email)` | 選填 | 發票通知 Email |
| `customId` | `string` | 選填 | 你自訂的資料,會原樣出現在付款通知與查詢結果中。 |
| `note` | `string` | 選填 | 備註。 |
| `use3d` | `boolean` | 選填 | 是否走 3D 驗證。網域被設定為一律 3D 時會強制開啟。預設:`false` |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X POST "https://payment-api.testing.oen.tw/checkout-subscription" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"amount": 299,
"numberOfPeriods": 12,
"orderId": "SUB20260928001",
"successUrl": "https://shop.example.com/subscribe/success?order=SUB20260928001",
"failureUrl": "https://shop.example.com/subscribe/failure/SUB20260928001",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "PLAN-M",
"description": "月費會員",
"quantity": 1,
"unit": "月",
"unitPrice": 299
}
]
}'
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/checkout-subscription", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"merchantId": "ming",
"amount": 299,
"numberOfPeriods": 12,
"orderId": "SUB20260928001",
"successUrl": "https://shop.example.com/subscribe/success?order=SUB20260928001",
"failureUrl": "https://shop.example.com/subscribe/failure/SUB20260928001",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "PLAN-M",
"description": "月費會員",
"quantity": 1,
"unit": "月",
"unitPrice": 299
}
]
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => 'ming',
'amount' => 299,
'numberOfPeriods' => 12,
'orderId' => 'SUB20260928001',
'successUrl' => 'https://shop.example.com/subscribe/success?order=SUB20260928001',
'failureUrl' => 'https://shop.example.com/subscribe/failure/SUB20260928001',
'userName' => '王小明',
'userEmail' => 'ming@example.com',
'productDetails' => [
[
'productionCode' => 'PLAN-M',
'description' => '月費會員',
'quantity' => 1,
'unit' => '月',
'unitPrice' => 299,
],
],
]),
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"POST",
"https://payment-api.testing.oen.tw/checkout-subscription",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": "ming",
"amount": 299,
"numberOfPeriods": 12,
"orderId": "SUB20260928001",
"successUrl": "https://shop.example.com/subscribe/success?order=SUB20260928001",
"failureUrl": "https://shop.example.com/subscribe/failure/SUB20260928001",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "PLAN-M",
"description": "月費會員",
"quantity": 1,
"unit": "月",
"unitPrice": 299,
},
],
},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ---------- |
| `200` | 建立成功 |
| `400` | 參數錯誤或金流未開通 |
| `401` | token 錯誤 |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| ---------------- | -------- | --- | ---------------- |
| `id` | `string` | 一定有 | 交易的內部 id,組結帳頁網址用 |
| `transactionHid` | `string` | 一定有 | 第一期交易的編號(`P` 開頭) |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"id": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"transactionHid": "P20260928AB12CD34"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ----------------------- |
| `V0001` | 400 | 欄位不符規則、品項合計不等於金額、發票資訊錯誤 |
| `V0002` | 400 | 網域的金流服務尚未開通 |
| `K0001` | 400 | 個人身分商家的交易金額超過風控門檻 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
流程、扣款時間與失敗處理見[定期定額](/developers/subscriptions/)。
# 建立綁卡頁
> 建立綁卡頁,消費者完成 3D 驗證後,你會從付款通知拿到 token。
POST `/checkout-token`
* 正式環境:`https://payment-api.oen.tw/checkout-token`
* 測試環境:`https://payment-api.testing.oen.tw/checkout-token`
建立綁卡頁,回傳 `id`。把消費者導到 `https://{merchantId}.oen.tw/checkout/subscription/create/{id}` 完成 3D 驗證並綁卡。**綁卡頁 10 分鐘內有效。token 只會透過付款通知(`purpose` 為 `token`)送給你**,導回網址不帶 token,也沒有查詢 API。
Header 帶 `Authorization: Bearer ` 與 `Content-Type: application/json` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### Body 欄位
[Section titled “Body 欄位”](#body-欄位)
| 欄位 | 型別 | 必填 | 說明 |
| ------------ | ---------------------- | -- | ------------------------------------------------------------------------------------------ |
| `merchantId` | `string` | 必填 | 你的網域名稱。例如應援頁是 `https://ming.oen.tw`,就填 `ming`。必須與 token 所屬的網域相同,不同會回 400 `V0001`。範例:`ming` |
| `successUrl` | `string (uri) \| null` | 必填 | 綁卡成功後導回的網址。**不帶 token**。限制:要寫完整網址(含 `https://`);可以是 `null`,但欄位一定要有 |
| `failureUrl` | `string (uri) \| null` | 必填 | 綁卡失敗或逾時後導回的網址,會加上 `payment_error`。限制:同 `successUrl`。建議不要自帶 `?` 參數與 `#` 片段 |
| `customId` | `string` | 選填 | 你自訂的資料,會出現在 token 通知裡,方便你對應是哪一位會員。 |
| `note` | `string` | 選填 | 備註。 |
| `payerEmail` | `string (email)` | 選填 | 持卡人 Email。沒帶時消費者要在綁卡頁自行填寫。 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X POST "https://payment-api.testing.oen.tw/checkout-token" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"successUrl": "https://shop.example.com/cards/added?member=M001",
"failureUrl": "https://shop.example.com/cards/failed/M001",
"customId": "M001",
"payerEmail": "ming@example.com"
}'
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/checkout-token", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"merchantId": "ming",
"successUrl": "https://shop.example.com/cards/added?member=M001",
"failureUrl": "https://shop.example.com/cards/failed/M001",
"customId": "M001",
"payerEmail": "ming@example.com"
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => 'ming',
'successUrl' => 'https://shop.example.com/cards/added?member=M001',
'failureUrl' => 'https://shop.example.com/cards/failed/M001',
'customId' => 'M001',
'payerEmail' => 'ming@example.com',
]),
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"POST",
"https://payment-api.testing.oen.tw/checkout-token",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": "ming",
"successUrl": "https://shop.example.com/cards/added?member=M001",
"failureUrl": "https://shop.example.com/cards/failed/M001",
"customId": "M001",
"payerEmail": "ming@example.com",
},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ---------- |
| `200` | 建立成功 |
| `400` | 參數錯誤或金流未開通 |
| `401` | token 錯誤 |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| ---- | -------- | --- | ------------------------------------- |
| `id` | `string` | 一定有 | 綁卡請求的 id。組綁卡頁網址用,也會出現在 token 通知的 `id` |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"id": "2HhngP9sVb3mK1xYdQe7Wc0RtUi"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ----------- |
| `V0001` | 400 | 欄位不符規則 |
| `V0002` | 400 | 網域的金流服務尚未開通 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
完整流程見[存卡與後續扣款](/developers/saved-cards/)。
# 錯誤碼一覽
> Payment API 回應的所有錯誤碼、HTTP 狀態碼與建議處理方式,以及結帳頁導回 failureUrl 時的 payment_error。
## API 回應的 code
[Section titled “API 回應的 code”](#api-回應的-code)
| 錯誤碼 | HTTP | 意思 | 建議處理 |
| ------- | ---- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `S0000` | 200 | 成功 | — |
| `A0001` | 401 | 未授權:token 缺少、格式錯誤、已被重新產生,或網域沒有開啟 API 串接 | 確認 header 是 `Authorization: Bearer `,以及用對了測試或正式環境的 token。token 被重新產生過就要換新的 |
| `V0001` | 400 | 請求參數錯誤 | 依 `message` 修正後重送。欄位驗證錯誤的訊息不含欄位路徑,請對照 API 參考逐欄檢查 |
| `V0002` | 400 | 狀態不允許:金流尚未開通,或交易、定期定額目前的狀態不能做這個動作 | 先查詢目前狀態再決定 |
| `V0003` | 400 | 超過單筆最高金額(200,000 元以上需要開通高額交易) | 拆成多筆,或聯絡應援開通 |
| `T0001` | 400 | 交易失敗(一般原因) | 請消費者換卡或稍後再試 |
| `T0002` | 400 | 安全碼錯誤 | 請消費者確認卡片資料 |
| `T0003` | 400 | 卡片過期 | 請消費者換卡 |
| `T0004` | 400 | 額度不足 | 請消費者換卡或聯絡發卡銀行 |
| `T0005` | 400 | 發卡銀行拒絕授權 | 請消費者聯絡發卡銀行或換卡 |
| `R0001` | 400 | 退款失敗 | 先查詢交易狀態。之後重試都回 `V0002` 時,代表退款結果需要人工確認,請聯絡應援 |
| `K0001` | 400 | 風控拒絕:個人身分商家的當月累計或單筆金額超過門檻 | 回應的 `detail.subCode` 說明原因,請聯絡應援 |
| `C026` | 409 | 付款結果不明,回應帶 `"retryable": false` | **不要重試,也不要換一筆新訂單重扣**。稍後用[查詢交易](/api/get-transaction/)或[訂單編號查詢](/api/list-order-transactions/)確認;久未確定請聯絡應援 |
| `F0001` | 500 | 系統錯誤或未分類的錯誤 | 看 `message`:`Invalid date`(日期參數錯誤)、`Pagination parse failed`(`page` 分頁標記無效)、`Current charged amount too low`(退款時網域待撥款金額不足)請依原因處理;其他請稍後重試,建立交易類的請求重試前先查詢 |
處理原則見[錯誤處理](/developers/errors/)。
## 結帳頁導回的 payment_error
[Section titled “結帳頁導回的 payment_error”](#結帳頁導回的-payment_error)
消費者付款失敗或逾時時,會被導回你的 `failureUrl`,網址加上 `payment_error=<代碼>`,例如 `https://shop.example.com/payment/failure/A001?payment_error=T0004`。
| 錯誤碼 | HTTP | 意思 | 建議處理 |
| ------- | ---- | ----------------------------------- | ------------- |
| `V0002` | 導回參數 | 結帳逾時、交易已處理過或找不到交易;未歸類的錯誤也是這個值 | 重新建立交易 |
| `V0003` | 導回參數 | 付款結果尚未確定。**和 API 回應的 `V0003` 意思不同** | 等付款通知,或稍後查詢交易 |
| `T0001` | 導回參數 | 付款失敗,包含 3D 驗證失敗 | 請消費者換卡或稍後再試 |
| `T0002` | 導回參數 | 安全碼錯誤 | 請消費者確認卡片資料 |
| `T0003` | 導回參數 | 卡片過期 | 請消費者換卡 |
| `T0004` | 導回參數 | 額度不足 | 請消費者換卡 |
| `T0005` | 導回參數 | 發卡銀行拒絕 | 請消費者聯絡發卡銀行或換卡 |
| `Y003` | 導回參數 | 綁卡頁已逾時(10 分鐘) | 重新建立綁卡頁 |
* 導回的代碼只用來顯示訊息給消費者,**請以付款通知或查詢結果為準**。
* LINE Pay 付款失敗或取消時,導回 `failureUrl` 不帶 `payment_error`。
## 其他 HTTP 狀態
[Section titled “其他 HTTP 狀態”](#其他-http-狀態)
| HTTP | 什麼時候 | body |
| ---- | --------------------------------------------------------- | ------------------------------------------ |
| 403 | 正式環境的呼叫來源 IP 還沒綁定,見[固定 IP 白名單](/developers/ip-allowlist/) | 只有 `message` 欄位(通常是 `Forbidden`),沒有 `code` |
| 404 | 路徑不存在 | 純文字 `Not Found` |
| 405 | 不支援的 HTTP method | 純文字 `Method Not Allowed` |
| 504 | 處理超過 29 秒。**請求可能已經成功**,先查詢再決定要不要重送 | — |
# 查詢定期定額明細
> 查詢一筆定期定額的狀態與下次扣款時間。
GET `/subscriptions/:id`
* 正式環境:`https://payment-api.oen.tw/subscriptions/:id`
* 測試環境:`https://payment-api.testing.oen.tw/subscriptions/:id`
查詢一筆 Payment API 定期定額的狀態、期數與下次扣款時間。
Header 帶 `Authorization: Bearer ` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### 路徑參數
[Section titled “路徑參數”](#路徑參數)
| 欄位 | 型別 | 必填 | 說明 |
| ---- | -------- | -- | -------------------------- |
| `id` | `string` | 必填 | `S` 開頭的定期定額編號,或定期定額的內部 id。 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X GET "https://payment-api.testing.oen.tw/subscriptions/S20260928EF56GH78" \
-H "Authorization: Bearer $OEN_API_TOKEN"
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/subscriptions/S20260928EF56GH78", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
},
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
],
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"GET",
"https://payment-api.testing.oen.tw/subscriptions/S20260928EF56GH78",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ------------------------------------------------ |
| `200` | 回傳 [Subscription 物件](/api/objects/subscription/) |
| `400` | 找不到,或不屬於你的網域(`V0001`) |
| `401` | token 錯誤 |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"id": "S20260928EF56GH78",
"status": "ongoing",
"period": 1,
"numberOfPeriods": 12,
"paymentInterval": 1,
"startedAt": "2026-09-28T02:44:35.592Z",
"endedAt": "2027-08-28T02:44:35.592Z",
"nextChargeAt": "2026-10-28T01:00:00.000Z",
"createdAt": "2026-09-28T02:44:35.592Z",
"amount": 299,
"userName": "王小明",
"orderId": "SUB20260928001"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ---------------- |
| `V0001` | 400 | 找不到定期定額,或不屬於你的網域 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
狀態說明見 [Subscription 物件](/api/objects/subscription/)。
# 查詢交易明細
> 查詢一筆交易的最新狀態。
GET `/transactions/:id`
* 正式環境:`https://payment-api.oen.tw/transactions/:id`
* 測試環境:`https://payment-api.testing.oen.tw/transactions/:id`
查詢一筆交易的最新狀態。收到付款通知後,請用這支回查確認結果再出貨。**請用 27 字元的 `id` 查詢**;用 `P` 開頭的交易編號也查得到,但回應不會有 `productDetails` 與 `numberOfPeriods`。
Header 帶 `Authorization: Bearer ` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### 路徑參數
[Section titled “路徑參數”](#路徑參數)
| 欄位 | 型別 | 必填 | 說明 |
| ---- | -------- | -- | ------------------------------------------------------ |
| `id` | `string` | 必填 | 交易的內部 id(建立交易時回傳的 `id`,或付款通知裡的 `id`),也可以是 `P` 開頭的交易編號。 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X GET "https://payment-api.testing.oen.tw/transactions/2HhndgEquCbDzC5OyVxWSGZmd2l" \
-H "Authorization: Bearer $OEN_API_TOKEN"
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/transactions/2HhndgEquCbDzC5OyVxWSGZmd2l", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
},
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
],
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"GET",
"https://payment-api.testing.oen.tw/transactions/2HhndgEquCbDzC5OyVxWSGZmd2l",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ---------------------------------------------- |
| `200` | 回傳 [Transaction 物件](/api/objects/transaction/) |
| `400` | 找不到交易,或交易不屬於你的網域(`V0001`) |
| `401` | token 錯誤 |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"id": "P20260928AB12CD34",
"transactionId": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"action": "onetime",
"amount": 1200,
"paymentMethod": "card",
"paymentInfo": {
"method": "card",
"cardNum": "424242******4242",
"cardName": "WANG XIAO MING",
"cardType": "Visa",
"cardIssuerCountry": "TW"
},
"status": "charged",
"userName": "王小明",
"userEmail": "ming@example.com",
"orderId": "A20260928001",
"createdAt": "2026-09-28T02:40:25.502Z",
"paidAt": "2026-09-28T02:41:03.118Z",
"refundAmount": 0,
"authCode": "831000",
"customId": "cart-8812",
"use3d": false,
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 2,
"unit": "包",
"unitPrice": 600
}
]
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ---------------- |
| `V0001` | 400 | 找不到交易,或交易不屬於你的網域 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
收到付款通知後怎麼回查,見[付款通知](/developers/webhooks/)。欄位說明見 [Transaction 物件](/api/objects/transaction/)。
# 用訂單編號查詢交易
> 列出同一個訂單編號的所有交易。
GET `/order/:orderId/transactions`
* 正式環境:`https://payment-api.oen.tw/order/:orderId/transactions`
* 測試環境:`https://payment-api.testing.oen.tw/order/:orderId/transactions`
列出同一個 `orderId` 的所有 API 交易,包含失敗的交易與定期定額每一期。一次回傳全部,不分頁,也不保證順序。消費者重試付款、或結帳頁過期後重新建立時,一個訂單可能有多筆交易,請用這支確認有沒有成功的那一筆。
Header 帶 `Authorization: Bearer ` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### 路徑參數
[Section titled “路徑參數”](#路徑參數)
| 欄位 | 型別 | 必填 | 說明 |
| --------- | -------- | -- | ------------------ |
| `orderId` | `string` | 必填 | 建立交易時帶的 `orderId`。 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X GET "https://payment-api.testing.oen.tw/order/A20260928001/transactions" \
-H "Authorization: Bearer $OEN_API_TOKEN"
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/order/A20260928001/transactions", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
},
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
],
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"GET",
"https://payment-api.testing.oen.tw/order/A20260928001/transactions",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ---------------- |
| `200` | 回傳交易陣列。查無資料時是空陣列 |
| `401` | token 錯誤 |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| -------------- | -------------------- | --- | ------------------------------------------------------------------ |
| `transactions` | `array` | 一定有 | [Transaction 物件](/api/objects/transaction/)的陣列,不含 `productDetails` |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"transactions": [
{
"id": "P20260928AB12CD34",
"transactionId": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"action": "onetime",
"amount": 1200,
"paymentMethod": "card",
"status": "charged",
"orderId": "A20260928001",
"createdAt": "2026-09-28T02:40:25.502Z",
"paidAt": "2026-09-28T02:41:03.118Z",
"refundAmount": 0
}
]
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | -------- |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
什麼時候會有多筆,見[查詢與對帳](/developers/querying/)。
# 查詢商店定期購訂單列表
> 列出應援商店的定期購訂單。
GET `/subscriptions`
* 正式環境:`https://payment-api.oen.tw/subscriptions`
* 測試環境:`https://payment-api.testing.oen.tw/subscriptions`
**列出的是應援商店的「定期購」訂單**,不是用 Payment API 建立的定期定額。只有在應援商店販售訂閱制商品的商家才需要。Payment API 定期定額請用[查詢定期定額明細](/api/get-subscription/)。依建立時間由新到舊,每頁最多 50 筆;用 `status` 篩選時,一頁可能少於 50 筆但仍有下一頁。
Header 帶 `Authorization: Bearer ` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### 查詢參數
[Section titled “查詢參數”](#查詢參數)
| 欄位 | 型別 | 必填 | 說明 |
| -------- | -------- | -- | ---------------------------------------------------------------------------------------------------- |
| `status` | `string` | 選填 | 用逗號分隔的狀態。不帶就是全部。可用值:`ongoing`、`cancelled`、`done`、`error`、`retryScheduled`範例:`ongoing,retryScheduled` |
| `page` | `string` | 選填 | 下一頁的分頁標記。把上一頁回應的 `page` 原封不動帶入。 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X GET "https://payment-api.testing.oen.tw/subscriptions?status=ongoing" \
-H "Authorization: Bearer $OEN_API_TOKEN"
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/subscriptions?status=ongoing", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
},
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
],
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"GET",
"https://payment-api.testing.oen.tw/subscriptions?status=ongoing",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | --------------- |
| `200` | 回傳定期購訂單列表與下一頁代碼 |
| `401` | token 錯誤 |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| ------------------------------------------------- | ---------------- | ---- | -------------------------------------------------------------------------- |
| `subscriptions` | `array` | 一定有 | 定期購訂單 |
| `id`subscriptions\[].id | `string` | 可能沒有 | 訂單的內部 id |
| `status`subscriptions\[].status | `string` | 可能沒有 | `ongoing` 進行中、`retryScheduled` 待重扣、`error` 扣款失敗、`cancelled` 已取消、`done` 已完成 |
| `items`subscriptions\[].items | `array` | 可能沒有 | 訂購的商品 |
| `totalAmount`subscriptions\[].totalAmount | `number` | 可能沒有 | 每期金額 |
| `period`subscriptions\[].period | `integer` | 可能沒有 | 已扣期數 |
| `numberOfPeriods`subscriptions\[].numberOfPeriods | `integer` | 可能沒有 | 總期數 |
| `lastChargedAt`subscriptions\[].lastChargedAt | `string` | 可能沒有 | 最近一次扣款時間(台北時間,帶 `+08:00`) |
| `user`subscriptions\[].user | `object` | 可能沒有 | 訂購人 `{ name, email }` |
| `createdAt`subscriptions\[].createdAt | `string` | 可能沒有 | 建立時間 |
| `cancelledAt`subscriptions\[].cancelledAt | `string` | 可能沒有 | 取消時間 |
| `endedAt`subscriptions\[].endedAt | `string` | 可能沒有 | 結束時間(台北時間,帶 `+08:00`) |
| `page` | `string \| null` | 一定有 | 下一頁的分頁標記;沒有下一頁時是 `null` |
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ------------- |
| `V0001` | 400 | `status` 值不正確 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
Payment API 建立的定期定額請用[查詢定期定額明細](/api/get-subscription/)。
# 查詢交易列表
> 依期間列出交易,用來對帳。
GET `/transactions`
* 正式環境:`https://payment-api.oen.tw/transactions`
* 測試環境:`https://payment-api.testing.oen.tw/transactions`
依建立時間由新到舊列出交易,每頁 50 筆。**列出的是網域的所有款項**,除了 API 建立的交易,也包含商店訂單、捐款等其他收款,以及撥款後退款產生的負數調整款項。只要 API 交易時,請用 `orderId` 比對,或改用[用訂單編號查詢](/api/list-order-transactions/)。
Header 帶 `Authorization: Bearer ` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### 查詢參數
[Section titled “查詢參數”](#查詢參數)
| 欄位 | 型別 | 必填 | 說明 |
| ------- | -------- | -- | ------------------------------------------------------------------------------------------------------- |
| `start` | `string` | 選填 | 起始日期,以台北時間的當天 00:00 起算。可以寫 `2026-09-01`,或 Unix 毫秒時間戳。限制:要和 `end` 一起帶;只帶一個會回 500 `F0001`(`Invalid date`) |
| `end` | `string` | 選填 | 結束日期,算到台北時間的當天 23:59:59。 |
| `page` | `string` | 選填 | 下一頁的分頁標記。把上一頁回應的 `page` 原封不動帶入。 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X GET "https://payment-api.testing.oen.tw/transactions?start=2026-09-01&end=2026-09-28" \
-H "Authorization: Bearer $OEN_API_TOKEN"
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/transactions?start=2026-09-01&end=2026-09-28", {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
},
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'GET',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
],
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"GET",
"https://payment-api.testing.oen.tw/transactions?start=2026-09-01&end=2026-09-28",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ------------------------------ |
| `200` | 回傳交易列表與下一頁代碼 |
| `401` | token 錯誤 |
| `500` | 日期格式錯誤或 `page` 分頁標記無效(`F0001`) |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| -------------- | -------------------- | --- | ------------------------------------------------------------------ |
| `transactions` | `array` | 一定有 | [Transaction 物件](/api/objects/transaction/)的陣列,不含 `productDetails` |
| `page` | `string \| null` | 一定有 | 下一頁的分頁標記;沒有下一頁時是 `null` |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"transactions": [
{
"id": "P20260928AB12CD34",
"transactionId": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"action": "onetime",
"amount": 1200,
"paymentMethod": "card",
"status": "charged",
"orderId": "A20260928001",
"createdAt": "2026-09-28T02:40:25.502Z",
"paidAt": "2026-09-28T02:41:03.118Z",
"refundAmount": 0
}
],
"page": "eyJMaW1pdCI6NTAsIkxhc3RFdmFsdWF0ZWRLZXkiOnt9fQ=="
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ------------------------------------- |
| `F0001` | 500 | `start`、`end` 只帶一個或格式錯誤;`page` 分頁標記無效 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
對帳做法見[查詢與對帳](/developers/querying/)。
# Subscription 物件
> 查詢定期定額明細的回應欄位與定期定額狀態的意思。
[查詢定期定額明細](/api/get-subscription/)的回應。沒有值的欄位不會出現。
| 欄位 | 型別 | 出現 | 說明 |
| ----------------- | --------- | ---- | ----------------------------------------------------------------------- |
| `id` | `string` | 一定有 | 定期定額編號(`S` 開頭,17 字元) |
| `status` | `string` | 一定有 | 狀態,見下方狀態表 |
| `period` | `integer` | 可能沒有 | 已扣期數 |
| `numberOfPeriods` | `integer` | 可能沒有 | 總期數。不限期時不會出現 |
| `paymentInterval` | `integer` | 可能沒有 | 每幾個月扣一次 |
| `amount` | `number` | 可能沒有 | 每期金額 |
| `startedAt` | `string` | 可能沒有 | 開始時間 |
| `endedAt` | `string` | 可能沒有 | 預計結束時間 |
| `nextChargeAt` | `string` | 可能沒有 | 下次扣款時間,只在 `ongoing` 時出現。固定是扣款日台北時間上午 9 點(例如 `2026-10-28T01:00:00.000Z`) |
| `cancelledAt` | `string` | 可能沒有 | 取消時間 |
| `reason` | `string` | 可能沒有 | 取消原因,或扣款失敗的原因 |
| `orderId` | `string` | 可能沒有 | 你的訂單編號 |
| `userId` | `string` | 可能沒有 | 你帶入的會員編號 |
| `userName` | `string` | 可能沒有 | 消費者姓名 |
| `note` | `string` | 可能沒有 | 備註 |
| `createdAt` | `string` | 一定有 | 建立時間 |
### 範例
```json
{
"id": "S20260928EF56GH78",
"status": "ongoing",
"period": 1,
"numberOfPeriods": 12,
"paymentInterval": 1,
"amount": 299,
"startedAt": "2026-09-28T02:44:35.592Z",
"endedAt": "2027-08-28T02:44:35.592Z",
"nextChargeAt": "2026-10-28T01:00:00.000Z",
"orderId": "SUB20260928001",
"userName": "王小明",
"createdAt": "2026-09-28T02:44:35.592Z"
}
```
## 定期定額狀態
[Section titled “定期定額狀態”](#定期定額狀態)
| status | 意思 | 在 CRM 顯示為 |
| ---------------- | ------------------------- | --------- |
| `initiated` | 已建立,消費者還沒完成結帳 | — |
| `processing` | 消費者正在結帳(例如 3D 驗證中) | — |
| `scheduled` | 首期日在未來,已完成綁卡,等待首期扣款 | — |
| `ongoing` | 進行中 | 進行中 |
| `retryScheduled` | 這一期扣款失敗,已排定重新扣款 | 異常 - 待重扣 |
| `error` | 扣款失敗且不再重試,**之後的期數都不會再扣款** | 異常 |
| `cancelled` | 已取消,或已到結束日 | 已取消 |
| `done` | 所有期數都扣完了 | 已結束 |
`trialing`、`terminated` 只會出現在 [Subscription API](/products/subscription-api/) 建立的訂閱。
可以取消的狀態:`scheduled`、`ongoing`、`retryScheduled`。見[取消定期定額](/api/cancel-subscription/)。
# Transaction 物件
> 交易查詢、列表與退款回應共用的 Transaction 物件:每個欄位、paymentInfo 的形狀,以及交易狀態的意思。
查詢交易、交易列表、用訂單編號查詢與退款的回應都用這個物件。沒有值的欄位不會出現。
| 欄位 | 型別 | 出現 | 說明 |
| ----------------- | --------- | ---- | --------------------------------------------------- |
| `id` | `string` | 一定有 | 交易編號。API 交易是 `P` 開頭共 17 字元;交易列表裡其他來源的款項是 `C` 開頭 |
| `transactionId` | `string` | 一定有 | 交易的內部 id(27 字元),和付款通知的 `id` 相同 |
| `action` | `string` | 可能沒有 | `onetime` 單次、`subscription` 定期定額 |
| `amount` | `number` | 一定有 | 金額。交易列表中的負數是撥款後退款產生的調整款項 |
| `fee` | `number` | 可能沒有 | 手續費 |
| `platformFee` | `number` | 可能沒有 | 平台費 |
| `paymentMethod` | `string` | 可能沒有 | `card`、`applePay`、`linePay`、`cvs`、`atm` |
| `paymentInfo` | `object` | 可能沒有 | 付款方式的細節,依付款方式不同,見下方說明 |
| `status` | `string` | 一定有 | 交易狀態,見下方狀態表 |
| `orderId` | `string` | 可能沒有 | 你的訂單編號 |
| `userId` | `string` | 可能沒有 | 你帶入的會員編號 |
| `userName` | `string` | 可能沒有 | 消費者姓名 |
| `userEmail` | `string` | 可能沒有 | 消費者 Email |
| `customId` | `string` | 可能沒有 | 你帶入的自訂資料 |
| `note` | `string` | 可能沒有 | 備註 |
| `reason` | `string` | 可能沒有 | 付款失敗的原因(收單機構回傳的原始訊息) |
| `authCode` | `string` | 可能沒有 | 信用卡授權碼 |
| `use3d` | `boolean` | 可能沒有 | 是否走了 3D 驗證 |
| `createdAt` | `string` | 一定有 | 建立時間(UTC,ISO 8601) |
| `paidAt` | `string` | 可能沒有 | 付款完成時間 |
| `refundAmount` | `number` | 可能沒有 | 已退款金額,預設 0 |
| `refundedAt` | `string` | 可能沒有 | 退款時間 |
| `payoutId` | `string` | 可能沒有 | 撥款單 id |
| `payoutAt` | `string` | 可能沒有 | 撥款時間 |
| `subscriptionId` | `string` | 可能沒有 | 所屬定期定額的編號(`S` 開頭),定期定額交易才有 |
| `period` | `integer` | 可能沒有 | 定期定額的第幾期 |
| `productDetails` | `array` | 可能沒有 | 商品明細。只在[查詢交易明細](/api/get-transaction/)並以內部 id 查詢時出現 |
| `numberOfPeriods` | `integer` | 可能沒有 | 定期定額總期數(0 為不限期)。出現條件同 `productDetails` |
| `success` | `boolean` | 可能沒有 | 只在退款回應中出現 |
### 範例
```json
{
"id": "P20260928AB12CD34",
"transactionId": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"action": "onetime",
"amount": 1200,
"paymentMethod": "card",
"paymentInfo": {
"method": "card",
"cardNum": "424242******4242",
"cardName": "WANG XIAO MING",
"cardType": "Visa",
"cardIssuerCountry": "TW"
},
"status": "charged",
"orderId": "A20260928001",
"userName": "王小明",
"userEmail": "ming@example.com",
"createdAt": "2026-09-28T02:40:25.502Z",
"paidAt": "2026-09-28T02:41:03.118Z",
"refundAmount": 0,
"authCode": "831000"
}
```
## paymentInfo
[Section titled “paymentInfo”](#paymentinfo)
依付款方式不同:
| 付款方式 | 內容 |
| -------------- | ------------------------------------------------------------------------------------------------------------------------- |
| 信用卡 | `method`、`cardNum`(遮罩為前 6 碼與後 4 碼,例如 `424242******4242`)、`cardName`、`cardType`(Visa、MasterCard、JCB 等)、`cardIssuerCountry` |
| 用 token 扣款的信用卡 | 只有 `{ "method": "card" }` |
| 超商代碼 | `method`、`cvsCode`、`cvsName`、`totalAmount`、`expiredAt`,以及 `credentials: { cvsName, code, expiredAt }` |
| ATM | `method`、`bankName`、`bankCode`、`account`(虛擬帳號)、`expiredAt` 等 |
| LINE Pay | `method`、`transactionId`(LINE Pay 交易編號)等 |
程式請只讀上表列出的欄位,其他欄位可能調整。
## 交易狀態
[Section titled “交易狀態”](#交易狀態)
| status | 意思 | 在 CRM 顯示為 |
| -------------------- | ------------------------------------------------- | ----------- |
| `initiated` | 已建立,消費者還沒付款。消費者離開結帳頁時會一直停在這個狀態,不會變成失敗 | 已建立付款意向 |
| `charging` | 處理中。超商代碼、ATM 已取號等待繳費;LINE Pay 等待確認;付款結果不明時也停在這裡 | 尚未付款 |
| `authorized` | 已授權、尚未請款(高額交易) | 已授權 |
| `charged` | 付款成功,款項由應援代收、等待撥款 | 付款成功 |
| `claimed` | 付款成功且款項已撥給你;LINE Pay、藍新等由收單機構直接撥款的付款方式,付款成功就是這個狀態 | 已入帳 |
| `failed` | 付款失敗或逾期未繳 | 扣款或授權失敗、已失效 |
| `cancelled` | 已取消(尚未繳費的超商代碼被取消) | 已取消 |
| `refunding` | 退款處理中(已付款的超商代碼、ATM 等待匯款) | 退款中 |
| `refunded` | 已退款。`refundAmount` 小於 `amount` 時是部分退款 | 全額退款、部分退款 |
| `refundedPostPayout` | 撥款後才退款,會從下次撥款扣回 | 撥款後退款 |
判斷「付款成功」請用 `charged` 或 `claimed`,不要只看 `charged`。`authorized` 只會出現在需要審核的高額交易,遇到時請聯絡應援確認後續流程。
# 付款通知內容
> Payment API 付款通知(webhook)的 HTTP 格式與每一種 purpose 的欄位:charge、token、schedule_subscription,以及需要另外開啟的退款與訂閱事件。
付款通知的處理方式、重送規則與回查做法,請先看[付款通知(webhook)](/developers/webhooks/)。這一頁列出每一種通知的欄位。
## HTTP 格式
[Section titled “HTTP 格式”](#http-格式)
* `POST` 到你在 CRM 設定的「交易資料回傳網址位置」。
* Header 只有 `Content-Type: application/json`。**沒有簽章,也沒有事件編號**。
* 用 body 的 `purpose` 分辨是哪一種通知。
| purpose | 什麼時候送 | 預設會送嗎 |
| ------------------------ | ----------------------------------------- | ------ |
| `charge` | 單次付款與定期定額每一期的結果;超商代碼、ATM 取號;LINE Pay 等待確認 | 會 |
| `token` | 綁卡頁完成或失敗 | 會 |
| `schedule_subscription` | 預約定期定額在結帳頁完成或失敗 | 會 |
| `refund` | 退款建立、完成或取消 | 要請應援開啟 |
| `subscription_cancelled` | 定期定額被取消(API 或 CRM) | 要請應援開啟 |
| `subscription_retry` | 定期定額扣款失敗後,已排定重扣或重扣次數用完 | 要請應援開啟 |
`refund`、`subscription_cancelled`、`subscription_retry` 三種要請應援在後台開啟「Webhook 新版事件」才會送出。
## charge
[Section titled “charge”](#charge)
單次付款、定期定額每一期的結果。同一筆交易可能收到多則,例如超商代碼先送取號(`charging`),繳費後再送 `charged`。
| 欄位 | 型別 | 出現 | 說明 |
| ----------------- | ------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `purpose` | `string` | 一定有 | `charge` |
| `id` | `string` | 一定有 | 交易的內部 id。用它呼叫 `GET /transactions/{id}` 回查 |
| `transactionId` | `string` | 一定有 | 同 `id` |
| `transactionHid` | `string` | 一定有 | 交易編號(`P` 開頭) |
| `merchantId` | `string` | 一定有 | 你的網域名稱 |
| `orderId` | `string` | 可能沒有 | 你的訂單編號 |
| `action` | `string` | 可能沒有 | `onetime` 或 `subscription` |
| `status` | `string` | 一定有 | 交易狀態:`charging`、`charged`、`claimed`、`authorized`、`failed` 等 |
| `success` | `boolean` | 可能沒有 | 只在最終結果才有:`status` 不是 `failed` 時為 `true`。取號、等待確認(`charging`)時沒有這個欄位 |
| `paymentMethod` | `string` | 可能沒有 | `card`、`applePay`、`linePay`、`cvs`、`atm` |
| `amount` | `number` | 可能沒有 | 金額 |
| `currency` | `string` | 可能沒有 | 幣別,**小寫**,例如 `twd` |
| `paymentInfo` | `string \| object` | 可能沒有 | 信用卡:卡號末四碼字串(Apple Pay 的付款通知可能不帶 `paymentInfo`);超商:`{ cvsName, code, expiredAt }`;ATM:`{ bankName, bankCode, account, expiredAt }`;LINE Pay:LINE Pay 交易編號 |
| `authCode` | `string` | 可能沒有 | 授權碼 |
| `customId` | `string` | 可能沒有 | 你帶入的自訂資料 |
| `productDetails` | `array` | 可能沒有 | 建立交易時的商品明細 |
| `userPhone` | `string` | 可能沒有 | 消費者在結帳頁填的手機 |
| `createdAt` | `string` | 可能沒有 | **交易建立時間**,不是通知送出的時間 |
| `paidAt` | `string` | 可能沒有 | 付款完成時間 |
| `message` | `string` | 可能沒有 | 失敗原因。超商代碼、ATM、LINE Pay 逾期時是 `PAYMENT_EXPIRED`;3D 驗證開始後 10 分鐘沒完成是 `3DS_ABANDONED` |
| `subscriptionId` | `string` | 可能沒有 | 定期定額編號(`S` 開頭),定期定額才有 |
| `period` | `integer` | 可能沒有 | 定期定額的第幾期 |
| `numberOfPeriods` | `integer` | 可能沒有 | 定期定額總期數 |
| `isRetry` | `boolean` | 可能沒有 | 定期定額:這次是否為失敗後的重新扣款 |
| `failureCode` | `string` | 可能沒有 | 定期定額扣款失敗的原因:`invalid_card_number`、`invalid_cvv`、`expired_card`、`exceeds_credit_limit`、`payment_refused`、`duplicate_payment`、`charge_failed` |
| `scheduleStatus` | `string` | 可能沒有 | 定期定額狀態:`pending`、`trialing`、`active`、`retrying`、`error`、`cancelled`、`completed` |
| `nextChargeAt` | `string` | 可能沒有 | 定期定額下次扣款時間。請以[查詢定期定額明細](/api/get-subscription/)為準 |
### 範例
```json
{
"purpose": "charge",
"action": "onetime",
"merchantId": "ming",
"orderId": "A20260928001",
"id": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"transactionId": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"transactionHid": "P20260928AB12CD34",
"status": "charged",
"success": true,
"paymentMethod": "card",
"amount": 1200,
"currency": "twd",
"paymentInfo": "4242",
"authCode": "831000",
"customId": "cart-8812",
"createdAt": "2026-09-28T02:40:25.502Z",
"paidAt": "2026-09-28T02:41:03.118Z",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 2,
"unit": "包",
"unitPrice": 600
}
],
"message": ""
}
```
超商代碼取號時(還沒繳費,沒有 `success` 欄位):
```json
{
"purpose": "charge",
"action": "onetime",
"merchantId": "ming",
"orderId": "A20260928002",
"id": "2HhnkQ3vXy8LpA1cB0dE9fG2hIj",
"transactionId": "2HhnkQ3vXy8LpA1cB0dE9fG2hIj",
"transactionHid": "P20260928UV12WX34",
"status": "charging",
"paymentMethod": "cvs",
"amount": 500,
"currency": "twd",
"paymentInfo": {
"cvsName": "超商代碼繳費",
"code": "XXXXXXXXXXXX",
"expiredAt": "2026-09-30T02:00:00.000Z"
},
"message": ""
}
```
消費者繳費後,同一個 `id` 會再收到一則 `status: charged`、`success: true` 的通知。逾期未繳則是 `status: failed`、`message: PAYMENT_EXPIRED`。
## token
[Section titled “token”](#token)
[建立綁卡頁](/api/checkout-token/)後,消費者完成或放棄綁卡時送出。**這是拿到 token 的唯一管道**。
| 欄位 | 型別 | 出現 | 說明 |
| --------------- | --------- | ---- | ---------------------------------------------------------- |
| `purpose` | `string` | 一定有 | `token` |
| `id` | `string` | 一定有 | 建立綁卡頁時回傳的 `id` |
| `transactionId` | `string` | 一定有 | 同 `id` |
| `merchantId` | `string` | 一定有 | 你的網域名稱 |
| `success` | `boolean` | 一定有 | 是否綁卡成功 |
| `token` | `string` | 可能沒有 | 成功時才有。用來呼叫[用 token 扣款](/api/token-transactions/),請當成密碼一樣保存 |
| `paymentInfo` | `string` | 可能沒有 | 卡號末四碼 |
| `customId` | `string` | 可能沒有 | 建立綁卡頁時帶的自訂資料 |
| `message` | `string` | 可能沒有 | 失敗原因。3D 驗證開始後 10 分鐘沒完成是 `3DS_ABANDONED` |
### 範例
```json
{
"purpose": "token",
"merchantId": "ming",
"id": "2HhngP9sVb3mK1xYdQe7Wc0RtUi",
"transactionId": "2HhngP9sVb3mK1xYdQe7Wc0RtUi",
"success": true,
"token": "2HhnhWm8Qe0sPz4LbXv9KdYt1Ra",
"paymentInfo": "4242",
"customId": "M001",
"message": ""
}
```
警告
沒有 API 可以回查 token。這則通知沒收到,token 就拿不回來,只能請消費者重新綁卡。請確認你的接收端穩定,並在 10 秒內回應 2xx。
## schedule_subscription
[Section titled “schedule_subscription”](#schedule_subscription)
[建立預約定期定額](/api/checkout-schedule/)後,消費者在結帳頁完成或失敗時送出。之後每一期的扣款結果以 `charge` 通知送出。
| 欄位 | 型別 | 出現 | 說明 |
| ----------------- | --------- | ---- | ----------------------- |
| `purpose` | `string` | 一定有 | `schedule_subscription` |
| `id` | `string` | 一定有 | 建立時回傳的 `id` |
| `subscriptionId` | `string` | 一定有 | 定期定額編號(`S` 開頭) |
| `merchantId` | `string` | 一定有 | 你的網域名稱 |
| `success` | `boolean` | 一定有 | 是否成功 |
| `amount` | `number` | 可能沒有 | 每期金額 |
| `currency` | `string` | 可能沒有 | 幣別(小寫) |
| `period` | `integer` | 可能沒有 | 已扣期數 |
| `numberOfPeriods` | `integer` | 可能沒有 | 總期數 |
| `interval` | `integer` | 可能沒有 | 每幾個月扣一次 |
| `startedAt` | `string` | 可能沒有 | 開始時間 |
| `nextChargeAt` | `string` | 可能沒有 | 下次扣款時間 |
| `paymentInfo` | `string` | 可能沒有 | 成功時為卡號末四碼 |
| `customId` | `string` | 可能沒有 | 你帶入的自訂資料 |
| `productDetails` | `array` | 可能沒有 | 商品明細 |
| `message` | `string` | 可能沒有 | 失敗原因 |
### 範例
```json
{
"purpose": "schedule_subscription",
"merchantId": "ming",
"id": "2HhnfZ1kqRmA7cX0TQwq8vBn3Ls",
"subscriptionId": "S20260928EF56GH78",
"success": true,
"amount": 1500,
"currency": "twd",
"period": 0,
"numberOfPeriods": 4,
"interval": 3,
"startedAt": "2026-10-15T00:00:00.000Z",
"nextChargeAt": "2026-10-15T01:00:00.000Z",
"paymentInfo": "4242",
"message": ""
}
```
## refund(需開啟)
[Section titled “refund(需開啟)”](#refund需開啟)
| 欄位 | 說明 |
| -------------------------- | ----------------------------------------------- |
| `purpose` | `refund` |
| `id` | 這則退款事件的 id,每則都不同 |
| `refundId` | 退款單 id |
| `chargeId`、`chargeHumanId` | 原交易的內部 id 與交易編號(`P` 開頭) |
| `amount`、`origAmount` | 退款金額、原交易金額 |
| `currency` | 幣別(小寫) |
| `refundStatus` | `refunding`(處理中)、`refunded`(完成)、`cancelled`(取消) |
| `reason` | 退款原因 |
| `paymentMethod` | 原交易的付款方式 |
| `requestedAt`、`resolvedAt` | 申請時間、完成時間 |
| `customId` | 原交易的自訂資料 |
| `subscriptionId`、`period` | 定期定額交易才有 |
## subscription_cancelled(需開啟)
[Section titled “subscription_cancelled(需開啟)”](#subscription_cancelled需開啟)
| 欄位 | 說明 |
| -------------------------- | ----------------------------------- |
| `purpose` | `subscription_cancelled` |
| `subscriptionId` | 定期定額編號 |
| `source` | 誰取消的:`merchant_api`(API)或 `crm`(後台) |
| `reason` | 取消原因 |
| `cancelledAt` | 取消時間 |
| `cancelledFromStatus` | 取消前的狀態 |
| `period`、`numberOfPeriods` | 已扣期數、總期數 |
| `customId` | 自訂資料 |
## subscription_retry(需開啟)
[Section titled “subscription_retry(需開啟)”](#subscription_retry需開啟)
| 欄位 | 說明 |
| -------------------------- | --------------------------------------------- |
| `purpose` | `subscription_retry` |
| `outcome` | `scheduled`:已排定重新扣款;`exhausted`:重扣次數用完,定期定額停止 |
| `retryAttemptNumber` | 第幾次重扣 |
| `nextRetryAt` | 下次重扣時間(`scheduled` 才有) |
| `failureCode` | 扣款失敗的原因 |
| `chargeId`、`chargeHumanId` | 失敗那一期的交易 |
| `subscriptionId`、`period` | 定期定額編號與期數 |
# 退款
> 為一筆交易退款。
POST `/refunds/:transactionHid`
* 正式環境:`https://payment-api.oen.tw/refunds/:transactionHid`
* 測試環境:`https://payment-api.testing.oen.tw/refunds/:transactionHid`
為一筆交易退款。**每筆交易只能用 API 成功退款一次**,部分退款後剩下的金額不能再退,請一次決定好金額。信用卡、Apple Pay、LINE Pay 即時退款;已付款的超商代碼與 ATM 要帶退款帳戶,由應援匯款;尚未繳費的超商代碼會直接取消代碼。
Header 帶 `Authorization: Bearer ` 與 `Content-Type: application/json` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### 路徑參數
[Section titled “路徑參數”](#路徑參數)
| 欄位 | 型別 | 必填 | 說明 |
| ---------------- | -------- | -- | ----------------------------------- |
| `transactionHid` | `string` | 必填 | `P` 開頭的交易編號(17 字元)。不接受 27 字元的內部 id。 |
### Body 欄位
[Section titled “Body 欄位”](#body-欄位)
| 欄位 | 型別 | 必填 | 說明 |
| ---------------------------------- | --------- | ---- | ------------------------------------------------------------------------------------------ |
| `merchantId` | `string` | 必填 | 你的網域名稱。例如應援頁是 `https://ming.oen.tw`,就填 `ming`。必須與 token 所屬的網域相同,不同會回 400 `V0001`。範例:`ming` |
| `amount` | `integer` | 選填 | 退款金額。不帶就是全額退款。限制:1 以上,不能大於原交易金額 |
| `reason` | `string` | 選填 | 退款原因。 |
| `productDetails` | `array` | 條件必填 | 原交易有開立電子發票時必填,用來開立折讓。品項合計必須等於退款金額。欄位同建立交易時的 `productDetails`。 |
| `remitInfo` | `object` | 條件必填 | 退款匯款帳戶。**已付款的超商代碼或 ATM 交易**必填;信用卡類與尚未繳費的超商代碼不用帶。 |
| `bankCode`remitInfo.bankCode | `string` | 必填 | 銀行代碼 |
| `bankName`remitInfo.bankName | `string` | 必填 | 銀行名稱 |
| `branchCode`remitInfo.branchCode | `string` | 必填 | 分行代碼 |
| `branchName`remitInfo.branchName | `string` | 必填 | 分行名稱 |
| `account`remitInfo.account | `string` | 必填 | 帳號限制:8 到 14 字元 |
| `accountName`remitInfo.accountName | `string` | 必填 | 戶名 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X POST "https://payment-api.testing.oen.tw/refunds/P20260928AB12CD34" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"amount": 600,
"reason": "消費者取消一包",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 1,
"unit": "包",
"unitPrice": 600
}
]
}'
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/refunds/P20260928AB12CD34", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"merchantId": "ming",
"amount": 600,
"reason": "消費者取消一包",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 1,
"unit": "包",
"unitPrice": 600
}
]
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => 'ming',
'amount' => 600,
'reason' => '消費者取消一包',
'productDetails' => [
[
'productionCode' => 'SKU-001',
'description' => '手沖咖啡豆 200g',
'quantity' => 1,
'unit' => '包',
'unitPrice' => 600,
],
],
]),
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"POST",
"https://payment-api.testing.oen.tw/refunds/P20260928AB12CD34",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": "ming",
"amount": 600,
"reason": "消費者取消一包",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 1,
"unit": "包",
"unitPrice": 600,
},
],
},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | --------------------------------------------------------------------------- |
| `200` | 退款已受理。回傳更新後的 [Transaction 物件](/api/objects/transaction/),另加 `success: true` |
| `400` | 參數錯誤、交易狀態不能退款,或收單機構退款失敗 |
| `401` | token 錯誤 |
| `500` | 系統錯誤,或網域待撥款金額不足(見下方錯誤表) |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"success": true,
"id": "P20260928AB12CD34",
"transactionId": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"action": "onetime",
"amount": 1200,
"paymentMethod": "card",
"status": "refunded",
"orderId": "A20260928001",
"createdAt": "2026-09-28T02:40:25.502Z",
"paidAt": "2026-09-28T02:41:03.118Z",
"refundAmount": 600,
"refundedAt": "2026-09-28T03:00:00.000Z",
"authCode": "831000"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `V0001` | 400 | 找不到交易或不屬於你的網域;金額錯誤(`INVALID_REFUND_AMOUNT`)或超過原金額(`REFUND_AMOUNT_EXCEED_CHARGE_AMOUNT`);缺少折讓品項(`PRODUCT_DETAILS_REQUIRED`、`PRODUCT_AMOUNT_NOT_MATCH`);缺少退款帳戶(`REMITTANCE_INFO_REQUIRED`) |
| `V0002` | 400 | 交易狀態不能退款,例如尚未付款、已經退過款 |
| `R0001` | 400 | 收單機構退款失敗。先查詢交易狀態;之後重試都回 `V0002` 時,請聯絡應援 |
| `F0001` | 500 | 訊息為 `Current charged amount too low`:你的網域尚未撥款的已收款淨額低於 200 元,暫時無法退款,請聯絡應援。其他訊息為系統錯誤 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
各付款方式的退款規則見[退款](/developers/refunds/)。
# 用 token 建立定期定額
> 用綁卡取得的 token 建立定期定額。
POST `/token/subscriptions`
* 正式環境:`https://payment-api.oen.tw/token/subscriptions`
* 測試環境:`https://payment-api.testing.oen.tw/token/subscriptions`
用綁卡取得的 token 建立定期定額。不帶 `startDate` 時當下扣第一期,結果以回應為準(第一期不送付款通知,之後每期會送);帶未來日期時只建立排程,到期才扣款。
Header 帶 `Authorization: Bearer ` 與 `Content-Type: application/json` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### Body 欄位
[Section titled “Body 欄位”](#body-欄位)
| 欄位 | 型別 | 必填 | 說明 |
| ------------------------------------------------ | ---------------- | ------ | ---------------------------------------------------------------------------------------------------------------- |
| `merchantId` | `string` | 必填 | 你的網域名稱。例如應援頁是 `https://ming.oen.tw`,就填 `ming`。必須與 token 所屬的網域相同,不同會回 400 `V0001`。範例:`ming` |
| `amount` | `integer` | 必填 | 每期金額(新台幣元)。必須等於 `productDetails` 的合計。限制:1 以上的整數範例:`1200` |
| `currency` | `string` | 選填 | 幣別,只能是 `TWD`。可用值:`TWD`預設:`TWD` |
| `token` | `string` | 必填 | 綁卡通知裡的 `token`。 |
| `numberOfPeriods` | `integer` | 選填 | 總期數。不帶就是不限期,直到取消。限制:2 以上 |
| `paymentInterval` | `integer` | 選填 | 每幾個月扣款一次。限制:1 到 12預設:`1` |
| `startDate` | `string` | 選填 | 首期扣款日(台北日期)。不帶就立即扣第一期。限制:`yyyy/MM/dd`;**必須晚於今天**,最晚 12 個月內 |
| `orderId` | `string` | 必填 | 你的訂單編號。之後可以用[用訂單編號查詢](/api/list-order-transactions/)找到這筆訂單的所有交易。限制:不檢查重複。同一個 `orderId` 可以建立多筆交易範例:`A20260928001` |
| `productDetails` | `array` | 必填 | 商品明細,用來開立電子發票與顯示在 CRM。**所有品項「數量 × 單價」的合計必須等於 `amount`**,否則回 400 `V0001`(`PRODUCT_AMOUNT_NOT_MATCH`)。 |
| `productionCode`productDetails\[].productionCode | `string` | 必填 | 商品代碼 |
| `description`productDetails\[].description | `string` | 必填 | 商品名稱。第一個品項的名稱也會當成交易的商品描述 |
| `quantity`productDetails\[].quantity | `integer` | 必填 | 數量限制:1 以上的整數 |
| `unit`productDetails\[].unit | `string` | 必填 | 單位,例如「個」「份」 |
| `unitPrice`productDetails\[].unitPrice | `integer` | 必填 | 單價(新台幣元) |
| `userId` | `string` | 選填 | 你系統裡的會員編號。 |
| `userName` | `string` | 條件必填 | 消費者姓名。網域有開通電子發票時必填。 |
| `userEmail` | `string (email)` | 條件必填 | 消費者 Email。網域有開通電子發票時必填,發票通知會寄到這裡。 |
| `userPhone` | `string` | 選填 | 消費者手機號碼。網域設定為手機必填時,必須是有效的電話號碼。 |
| `invoiceInfo` | `object` | 選填 | 電子發票資訊。網域有開通電子發票時才有作用;沒帶時開立雲端發票。 |
| `invoiceType`invoiceInfo.invoiceType | `string` | 必填 | `cloud`:雲端發票;`company`:公司戶(打統編)可用值:`cloud`、`company` |
| `carrierType`invoiceInfo.carrierType | `string` | 雲端發票必填 | 載具類型:`3J0002` 手機條碼、`CQ0001` 自然人憑證、空字串為會員載具可用值:`3J0002`、`CQ0001`、`` |
| `carrierId`invoiceInfo.carrierId | `string` | 選填 | 載具號碼。手機條碼格式為 `/` 加 7 碼,會即時向財政部驗證;自然人憑證為 2 個英文字母加 14 碼數字 |
| `buyerIdentifier`invoiceInfo.buyerIdentifier | `string` | 公司戶必填 | 買受人統一編號,8 碼,會檢查檢查碼 |
| `buyerName`invoiceInfo.buyerName | `string` | 選填 | 買受人名稱 |
| `email`invoiceInfo.email | `string (email)` | 選填 | 發票通知 Email |
| `note` | `string` | 選填 | 備註。 |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X POST "https://payment-api.testing.oen.tw/token/subscriptions" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"amount": 299,
"token": "2HhnhWm8Qe0sPz4LbXv9KdYt1Ra",
"numberOfPeriods": 12,
"paymentInterval": 1,
"orderId": "SUB20260928003",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "PLAN-M",
"description": "月費會員",
"quantity": 1,
"unit": "月",
"unitPrice": 299
}
]
}'
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/token/subscriptions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"merchantId": "ming",
"amount": 299,
"token": "2HhnhWm8Qe0sPz4LbXv9KdYt1Ra",
"numberOfPeriods": 12,
"paymentInterval": 1,
"orderId": "SUB20260928003",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "PLAN-M",
"description": "月費會員",
"quantity": 1,
"unit": "月",
"unitPrice": 299
}
]
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => 'ming',
'amount' => 299,
'token' => '2HhnhWm8Qe0sPz4LbXv9KdYt1Ra',
'numberOfPeriods' => 12,
'paymentInterval' => 1,
'orderId' => 'SUB20260928003',
'userName' => '王小明',
'userEmail' => 'ming@example.com',
'productDetails' => [
[
'productionCode' => 'PLAN-M',
'description' => '月費會員',
'quantity' => 1,
'unit' => '月',
'unitPrice' => 299,
],
],
]),
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"POST",
"https://payment-api.testing.oen.tw/token/subscriptions",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": "ming",
"amount": 299,
"token": "2HhnhWm8Qe0sPz4LbXv9KdYt1Ra",
"numberOfPeriods": 12,
"paymentInterval": 1,
"orderId": "SUB20260928003",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "PLAN-M",
"description": "月費會員",
"quantity": 1,
"unit": "月",
"unitPrice": 299,
},
],
},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ----------------------------- |
| `200` | 建立成功 |
| `400` | 第一期扣款失敗(`T0001`~`T0005`)或參數錯誤 |
| `401` | token 錯誤 |
| `409` | 第一期付款結果不明(`C026`),不要重試 |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| ---------------- | -------- | ---- | ----------------------------------- |
| `subscriptionId` | `string` | 一定有 | 定期定額編號(`S` 開頭) |
| `transactionId` | `string` | 可能沒有 | 第一期交易編號(`P` 開頭)。帶未來 `startDate` 時沒有 |
| `authCode` | `string` | 可能沒有 | 第一期的授權碼。帶未來 `startDate` 時沒有 |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"subscriptionId": "S20260928MN34OP56",
"transactionId": "P20260928QR78ST90",
"authCode": "831000"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ---------------------------------------------------------------------------------------- |
| `T0001` | 400 | 第一期扣款失敗。定期定額不會成立 |
| `V0001` | 400 | 欄位不符規則;首期日不存在(`INVALID_START_DATE`)、不晚於今天(`START_DATE_MUST_GREATER_THAN_TODAY`)或超過 12 個月 |
| `V0002` | 400 | 網域的金流服務尚未開通 |
| `C026` | 409 | 第一期付款結果不明 |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
見[存卡與後續扣款](/developers/saved-cards/)與[定期定額](/developers/subscriptions/)。
# 用 token 扣款
> 用綁卡取得的 token 直接扣款,消費者不需要在場。
POST `/token/transactions`
* 正式環境:`https://payment-api.oen.tw/token/transactions`
* 測試環境:`https://payment-api.testing.oen.tw/token/transactions`
用綁卡取得的 token 直接扣款,消費者不需要在場,也不走 3D 驗證。**結果以這支 API 的回應為準,不會送付款通知**。沒有防重複扣款的機制:逾時或收到 5xx 時,請先用[訂單編號查詢](/api/list-order-transactions/)確認,不要直接重送。
Header 帶 `Authorization: Bearer ` 與 `Content-Type: application/json` 。token 的取得方式見[驗證與 token](/developers/authentication/)。
## 請求
[Section titled “請求”](#請求)
### Body 欄位
[Section titled “Body 欄位”](#body-欄位)
| 欄位 | 型別 | 必填 | 說明 |
| ------------------------------------------------ | ---------------- | ------ | ---------------------------------------------------------------------------------------------------------------- |
| `merchantId` | `string` | 必填 | 你的網域名稱。例如應援頁是 `https://ming.oen.tw`,就填 `ming`。必須與 token 所屬的網域相同,不同會回 400 `V0001`。範例:`ming` |
| `amount` | `integer` | 必填 | 金額(新台幣元)。必須等於 `productDetails` 各品項「數量 × 單價」的合計。限制:1 以上的整數範例:`1200` |
| `currency` | `string` | 選填 | 幣別,只能是 `TWD`。可用值:`TWD`預設:`TWD` |
| `token` | `string` | 必填 | 綁卡通知裡的 `token`。 |
| `orderId` | `string` | 必填 | 你的訂單編號。之後可以用[用訂單編號查詢](/api/list-order-transactions/)找到這筆訂單的所有交易。限制:不檢查重複。同一個 `orderId` 可以建立多筆交易範例:`A20260928001` |
| `productDetails` | `array` | 必填 | 商品明細,用來開立電子發票與顯示在 CRM。**所有品項「數量 × 單價」的合計必須等於 `amount`**,否則回 400 `V0001`(`PRODUCT_AMOUNT_NOT_MATCH`)。 |
| `productionCode`productDetails\[].productionCode | `string` | 必填 | 商品代碼 |
| `description`productDetails\[].description | `string` | 必填 | 商品名稱。第一個品項的名稱也會當成交易的商品描述 |
| `quantity`productDetails\[].quantity | `integer` | 必填 | 數量限制:1 以上的整數 |
| `unit`productDetails\[].unit | `string` | 必填 | 單位,例如「個」「份」 |
| `unitPrice`productDetails\[].unitPrice | `integer` | 必填 | 單價(新台幣元) |
| `userId` | `string` | 選填 | 你系統裡的會員編號。 |
| `userName` | `string` | 條件必填 | 消費者姓名。網域有開通電子發票時必填。 |
| `userEmail` | `string (email)` | 條件必填 | 消費者 Email。網域有開通電子發票時必填,發票通知會寄到這裡。 |
| `userPhone` | `string` | 選填 | 消費者手機號碼。網域設定為手機必填時,必須是有效的電話號碼。 |
| `invoiceInfo` | `object` | 選填 | 電子發票資訊。網域有開通電子發票時才有作用;沒帶時開立雲端發票。 |
| `invoiceType`invoiceInfo.invoiceType | `string` | 必填 | `cloud`:雲端發票;`company`:公司戶(打統編)可用值:`cloud`、`company` |
| `carrierType`invoiceInfo.carrierType | `string` | 雲端發票必填 | 載具類型:`3J0002` 手機條碼、`CQ0001` 自然人憑證、空字串為會員載具可用值:`3J0002`、`CQ0001`、`` |
| `carrierId`invoiceInfo.carrierId | `string` | 選填 | 載具號碼。手機條碼格式為 `/` 加 7 碼,會即時向財政部驗證;自然人憑證為 2 個英文字母加 14 碼數字 |
| `buyerIdentifier`invoiceInfo.buyerIdentifier | `string` | 公司戶必填 | 買受人統一編號,8 碼,會檢查檢查碼 |
| `buyerName`invoiceInfo.buyerName | `string` | 選填 | 買受人名稱 |
| `email`invoiceInfo.email | `string (email)` | 選填 | 發票通知 Email |
| `note` | `string` | 選填 | 備註。 |
| `expectedPayoutDate` | `string` | 選填 | 期望撥款日期(台北日期),會顯示在 CRM 金流明細。限制:`yyyy/MM/dd` |
### 請求範例
[Section titled “請求範例”](#請求範例)
範例一律打測試環境。把 `OEN_API_TOKEN` 設成測試環境 CRM 產生的 token。
* cURL
```bash
curl -X POST "https://payment-api.testing.oen.tw/token/transactions" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"amount": 1200,
"token": "2HhnhWm8Qe0sPz4LbXv9KdYt1Ra",
"orderId": "A20260928003",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 2,
"unit": "包",
"unitPrice": 600
}
]
}'
```
* Node.js
```js
const res = await fetch("https://payment-api.testing.oen.tw/token/transactions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OEN_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
"merchantId": "ming",
"amount": 1200,
"token": "2HhnhWm8Qe0sPz4LbXv9KdYt1Ra",
"orderId": "A20260928003",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 2,
"unit": "包",
"unitPrice": 600
}
]
}),
});
const result = await res.json();
if (result.code !== "S0000") {
throw new Error(`${result.code} ${result.message}`);
}
```
* PHP
```php
'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('OEN_API_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'merchantId' => 'ming',
'amount' => 1200,
'token' => '2HhnhWm8Qe0sPz4LbXv9KdYt1Ra',
'orderId' => 'A20260928003',
'userName' => '王小明',
'userEmail' => 'ming@example.com',
'productDetails' => [
[
'productionCode' => 'SKU-001',
'description' => '手沖咖啡豆 200g',
'quantity' => 2,
'unit' => '包',
'unitPrice' => 600,
],
],
]),
]);
$result = json_decode(curl_exec($ch), true);
if ($result['code'] !== 'S0000') {
throw new Exception($result['code'] . ' ' . $result['message']);
}
```
* Python
```python
import os
import requests
res = requests.request(
"POST",
"https://payment-api.testing.oen.tw/token/transactions",
headers={"Authorization": f"Bearer {os.environ['OEN_API_TOKEN']}"},
json={
"merchantId": "ming",
"amount": 1200,
"token": "2HhnhWm8Qe0sPz4LbXv9KdYt1Ra",
"orderId": "A20260928003",
"userName": "王小明",
"userEmail": "ming@example.com",
"productDetails": [
{
"productionCode": "SKU-001",
"description": "手沖咖啡豆 200g",
"quantity": 2,
"unit": "包",
"unitPrice": 600,
},
],
},
timeout=30,
)
result = res.json()
if result["code"] != "S0000":
raise RuntimeError(f"{result['code']} {result['message']}")
```
## 回應
[Section titled “回應”](#回應)
| HTTP | 說明 |
| ----- | ---------------------------------------------- |
| `200` | 扣款成功 |
| `400` | 扣款失敗(`T0001`~`T0005`)或參數錯誤。失敗的交易仍會建立,可以用訂單編號查到 |
| `401` | token 錯誤 |
| `409` | 付款結果不明(`C026`)。**不要重試**,先查詢交易狀態 |
### 回應欄位
[Section titled “回應欄位”](#回應欄位)
| 欄位 | 型別 | 出現 | 說明 |
| ---------- | -------- | ---- | ------------------ |
| `id` | `string` | 一定有 | 交易編號(`P` 開頭),退款用這個 |
| `authCode` | `string` | 可能沒有 | 授權碼 |
### 回應範例
[Section titled “回應範例”](#回應範例)
```json
{
"code": "S0000",
"data": {
"id": "P20260928IJ90KL12",
"authCode": "831000"
},
"message": ""
}
```
## 可能的錯誤
[Section titled “可能的錯誤”](#可能的錯誤)
| 錯誤碼 | HTTP | 什麼時候會發生 |
| ------- | ---- | ------------------------------- |
| `T0001` | 400 | 交易失敗(一般原因) |
| `T0002` | 400 | 安全碼錯誤 |
| `T0003` | 400 | 卡片過期 |
| `T0004` | 400 | 額度不足 |
| `T0005` | 400 | 發卡銀行拒絕授權 |
| `V0001` | 400 | 欄位不符規則、品項合計不等於金額 |
| `V0002` | 400 | 網域的金流服務尚未開通 |
| `V0003` | 400 | 單筆 200,000 元以上,但網域沒有開通高額交易 |
| `C026` | 409 | 付款結果不明,回應帶 `"retryable": false` |
| `A0001` | 401 | token 錯誤 |
完整清單與處理方式見[錯誤碼一覽](/api/error-codes/)。
## 相關說明
[Section titled “相關說明”](#相關說明)
什麼時候適合用、如何避免重複扣款,見[存卡與後續扣款](/developers/saved-cards/)。
# 用 AI 助手串接
> 讓 Claude、ChatGPT、Cursor 等 AI 助手根據最新文件幫你寫串接程式。
AI 助手寫金流串接程式很快,但它記得的常常是舊版規則。最可靠的做法,是把本站的最新內容直接交給它。
## 給 AI 讀的檔案
[Section titled “給 AI 讀的檔案”](#給-ai-讀的檔案)
| 檔案 | 內容 | 適合 |
| -------------------------------------------------------- | ---------------------------- | ----------------------- |
| [`/llms.txt`](/llms.txt) | 全站頁面索引與摘要 | 讓 AI 知道有哪些頁面,再自己去讀 |
| [`/llms-full.txt`](/llms-full.txt) | 全站內容的純文字版 | 一次交給 AI 完整內容 |
| [`/openapi/payment-api.json`](/openapi/payment-api.json) | Payment API 的 OpenAPI 3.1 定義 | 產生 client 程式、匯入 Postman |
使用方式:
* **只需要一頁時**:每一頁標題下方都有「複製成 Markdown(給 AI 用)」,以及「在 ChatGPT 開啟」「在 Claude 開啟」。
* **Claude、ChatGPT 等對話介面**:把 `https://developer.oen.tw/llms-full.txt` 的網址或內容貼進對話,再說明你要做什麼。
* **Cursor、Claude Code 等開發工具**:把 `llms-full.txt` 下載到專案,或在工具的文件設定加入本站網址。
* **Postman**:選 Import,貼上 `https://developer.oen.tw/openapi/payment-api.json`。
不要把 token 貼給 AI
API token、Secret Key、Webhook Secret 都不要貼進任何 AI 對話或聊天工具。範例程式請用環境變數(例如 `OEN_API_TOKEN`)。如果已經貼出去了,請到 CRM 重新產生,舊的會立即失效。
## 請 AI 做事的範例
[Section titled “請 AI 做事的範例”](#請-ai-做事的範例)
```text
請依照 https://developer.oen.tw/llms-full.txt 的應援 Payment API 文件,
用 Node.js(Express)寫:
1. 建立單次付款結帳頁的 API(金額與品項由我的訂單資料計算)
2. 接收付款通知的 endpoint:收到後用 GET /transactions/{id} 回查狀態再更新訂單
3. token 從環境變數 OEN_API_TOKEN 讀取,先打測試環境
```
拿到程式後,請對照[上線檢查清單](/developers/go-live/)逐項確認,特別是:
* `productDetails` 的品項合計要等於 `amount`。
* 付款通知沒有簽章,一定要回查。
* 付款結果不確定(HTTP 409)時,不要直接重新扣款。
## 應援提供的 AI 工具
[Section titled “應援提供的 AI 工具”](#應援提供的-ai-工具)
[Payment Skill](/ai/skill/)給 Claude Code 等 AI 開發工具的技能檔,公開可安裝。
[Payment MCP](/ai/mcp/)讓 AI 直接呼叫 Payment API 的 MCP server。目前是內部預覽版。
# Payment MCP
> 讓 AI 助手直接建立結帳頁、查詢交易的 MCP server。目前是內部預覽版,尚未對外開放。
內部預覽,尚未對外開放
Payment MCP 目前只提供應援內部測試,外部商家還無法安裝,也只能連到測試環境。正式開放時會在[更新紀錄](/changelog/)公告。
現在想讓 AI 協助串接,請改用 [Payment Skill](/ai/skill/) 或把 [`/llms-full.txt`](/llms-full.txt) 提供給 AI。
## 它能做什麼
[Section titled “它能做什麼”](#它能做什麼)
[MCP(Model Context Protocol)](https://modelcontextprotocol.io/) 讓 AI 助手可以直接呼叫外部工具。Payment MCP 把 Payment API 包成 AI 可以使用的工具,例如在對話中說「幫我開一個 500 元的結帳連結」,AI 就會呼叫 API 並回傳結帳頁網址。
預覽版提供的工具:
| 工具 | 對應的 API |
| --------------------------------- | ---------------------------------------- |
| `checkout_link` | [建立單次付款](/api/checkout/) |
| `subscription_checkout` | [建立定期定額](/api/checkout-subscription/) |
| `scheduled_subscription_checkout` | [建立預約定期定額](/api/checkout-schedule/) |
| `exchange_token_by_3d` | [建立綁卡頁](/api/checkout-token/) |
| `get_transaction` | [查詢交易明細](/api/get-transaction/) |
| `get_transactions` | [查詢交易列表](/api/list-transactions/) |
| `get_transactions_by_order_id` | [用訂單編號查詢](/api/list-order-transactions/) |
| `get_subscription` | [查詢定期定額明細](/api/get-subscription/) |
| `cancel_subscription` | [取消定期定額](/api/cancel-subscription/) |
| `read_oen_docs` | 讀取內建的開發者文件(本站文件的快照,可以指定頁面) |
| `get_config` | 查看目前的設定(固定連測試環境) |
預覽版沒有退款與用 token 扣款的工具。MCP 只連測試環境,不需要設定環境;指定正式環境或填入正式環境的 token 都會拒絕啟動。
## 安全提醒
[Section titled “安全提醒”](#安全提醒)
MCP 會用你的 API token 實際呼叫 API。正式開放後,也請先在測試環境使用,並只把測試環境的 token 交給 AI 工具。
# Payment Skill
> 安裝應援的 Payment Skill,讓 Claude Code 等 AI 開發工具在你提到金流時自動帶入串接知識。
Payment Skill 是一份給 AI 開發工具讀的技能檔(`SKILL.md` 加上參考文件)。安裝後,當你在工具裡提到「應援金流」「oen payment」「定期定額」「退款」等關鍵字,AI 會自動載入 Payment API 的串接知識,幫你產生程式、解釋錯誤碼。
* 原始碼:[github.com/OEN-Tech/oen-payment-skill](https://github.com/OEN-Tech/oen-payment-skill)(MIT 授權)
* 支援:Claude Code,以及其他支援 `SKILL.md` 格式的工具
## 安裝
[Section titled “安裝”](#安裝)
1. 把 repo 複製到工具的技能目錄。以 Claude Code 為例:
```bash
# 所有專案都能用
git clone https://github.com/OEN-Tech/oen-payment-skill.git ~/.claude/skills/oen-payment
# 或只給目前的專案用
git clone https://github.com/OEN-Tech/oen-payment-skill.git .claude/skills/oen-payment
```
2. 重新啟動 AI 工具。
3. 試著問:「幫我用 TypeScript 串接應援金流,建立一個結帳頁」。
## 使用前要知道
[Section titled “使用前要知道”](#使用前要知道)
Skill 的參考文件從本站匯出
Skill 的 `references/docs/` 是從本站匯出的,內容與匯出當時的本站一致。兩邊如果不同,以本站為準,因為本站比較新。建議同時把 [`/llms-full.txt`](/llms-full.txt) 提供給 AI。
幾個 AI 最容易寫錯、請你特別檢查的地方:
* `productDetails` 在所有結帳 API 都是**必填**,品項的「數量 × 單價」合計必須等於 `amount`。見[單次付款](/developers/one-time/)。
* 退款不只信用卡:LINE Pay、超商代碼、ATM 也能用 API 退,超商與 ATM 要帶退款帳戶。見[退款](/developers/refunds/)。
* 付款通知最多送 3 次,間隔 2 秒、4 秒,而且沒有簽章。見[付款通知](/developers/webhooks/)。
* 取消定期定額只接受 `S` 開頭的定期定額編號。見[取消定期定額](/api/cancel-subscription/)。
* Payment MCP 目前是內部預覽版,尚未對外開放,見 [Payment MCP](/ai/mcp/)。
## 串接仍然需要 token
[Section titled “串接仍然需要 token”](#串接仍然需要-token)
Skill 只提供知識,實際呼叫 API 仍需要 CRM 產生的 token。取得方式見[驗證與 token](/developers/authentication/)。
# 更新紀錄
> Payment API 與文件的變更紀錄。
## 文件
[Section titled “文件”](#文件)
### 2026-09 新文件網站
[Section titled “2026-09 新文件網站”](#2026-09-新文件網站)
* Payment API 文件從 Postman 移到本站,依正式環境 v10.3.1.1 逐項核對。舊的 Postman 文件保留但不再更新。
* 新增:存卡與後續扣款(`/checkout-token`、`/token/transactions`、`/token/subscriptions`)、付款通知的完整欄位、錯誤碼一覽、固定 IP 白名單、環境與測試、商家操作指南。
* 更正舊文件:
* 所有建立交易的 API 都**必須**帶 `productDetails`,品項合計要等於 `amount`。
* 付款通知最多送 3 次,間隔約 2 秒、4 秒(舊文件寫 2、4、6 秒)。
* 退款不只信用卡:Apple Pay、LINE Pay、超商代碼、ATM 都可以用 API 退。`amount` 不帶就是全額。
* `POST /checkout-schedule` 的回應是 `subscriptionHid`,不是 `transactionHid`。
* 失敗導回的 `payment_error` 不會是 `V0001`。
* 「金額大於 100 元成功」不是應援系統的規則,請不要依賴。
* 新增 [OpenAPI 定義檔](/openapi/payment-api.json) 與 [llms.txt](/llms.txt)。
## Payment API
[Section titled “Payment API”](#payment-api)
### v10.3.1(2026-09)
[Section titled “v10.3.1(2026-09)”](#v10312026-09)
* 結帳頁與綁卡頁會先擋下已過期的信用卡。
* 付款結果不明的交易不再自動重試扣款,避免重複扣款。
### v10.3.0(2026-09)
[Section titled “v10.3.0(2026-09)”](#v10302026-09)
* **`allowedPaymentMethods` 在結帳頁實際生效**:結帳頁照清單顯示付款方式並把關。信用卡仍然一定會出現,`["cvs"]` 是信用卡加超商代碼。
* 新增 409 `C026`(付款結果不明),回應帶 `"retryable": false`。
* 綁卡頁(`/checkout-token`)與預約定期定額:部分收單機構綁卡時會先授權新台幣 1 元再退回,頁面會事先告知消費者。
* 定期定額的結帳頁,付款人 Email 改為必填。
* 修正定期定額在特定情況下停止扣款的問題。
# 驗證與 token
> Payment API 的 token 怎麼產生、怎麼帶、會不會過期、重新產生的影響,以及 401 的常見原因。
Payment API 用 CRM 產生的 token 驗證。每個網域在每個環境同時只有一把有效的 token。
## 產生 token
[Section titled “產生 token”](#產生-token)
1. 確認網域已開啟 API 串接(開發者模式)。沒開時 CRM 不會出現「開發者」分頁,請商家聯絡應援。
2. 進入 CRM「總設定」→「開發者」分頁 →「應援金流設定」→「存取 Token」,按「產生 Token」。
3. 立刻複製保存。離開頁面後就看不到完整的 token。
需要後台管理員,或「金流串接管理員」角色。詳細步驟見[交給工程師串接前的準備](/merchant/api-access/)。
## 帶上 token
[Section titled “帶上 token”](#帶上-token)
```http
Authorization: Bearer
```
* `Bearer` 要照這個大小寫,中間只有一個空白。
* 有 body 的請求要帶 `merchantId`,而且必須是 token 所屬的網域,否則回 400 `V0001`。查詢類的 GET 請求不需要帶,網域由 token 決定。
## token 的特性
[Section titled “token 的特性”](#token-的特性)
| 特性 | 說明 |
| ---- | ---------------------------------- |
| 有效期限 | **沒有期限**,直到重新產生 |
| 重新產生 | 舊 token **立刻失效**,沒有緩衝期 |
| 環境 | 測試與正式各自產生,不能互用 |
| 數量 | 每個網域、每個環境同時只有一把 |
| 放在哪裡 | 只放伺服器。API 不接受瀏覽器跨網域請求,也不要放進前端或 App |
更換 token 會中斷服務
重新產生後,舊 token 立刻失效,CRM 也沒有確認視窗。要更換時,請事先安排:
1. 準備好能立即更新 token 的部署方式。
2. 在離峰時間產生新 token。
3. 立刻部署,並確認 API 呼叫恢復正常。
## token 外洩時
[Section titled “token 外洩時”](#token-外洩時)
1. 立刻到 CRM 重新產生,讓外洩的 token 失效。
2. 更新伺服器設定。
3. 用[查詢交易列表](/api/list-transactions/)檢查外洩期間有沒有異常的建立或退款。
請不要把 token 貼到 email、聊天工具、AI 助手或公開的程式碼庫。
## 收到 401 `A0001`
[Section titled “收到 401 A0001”](#收到-401-a0001)
| 可能原因 | 怎麼確認 |
| ------------- | --------------------------------------------- |
| token 被重新產生過 | 問商家最近有沒有按過「重新產生 Token」 |
| 用錯環境 | 測試環境的 token 打到正式環境,或反過來 |
| header 格式錯誤 | 確認是 `Authorization: Bearer `,沒有多餘空白或換行 |
| 網域的 API 串接被關閉 | 請商家聯絡應援 |
正式環境收到的是 **403**(沒有 `code` 欄位)時,原因是 IP 還沒綁定,不是 token 的問題,見[固定 IP 白名單](/developers/ip-allowlist/)。
# 環境與測試
> 測試環境與正式環境的網址、token、IP 限制與付款通知的差異,以及測試付款時要注意的事。
## 兩個環境
[Section titled “兩個環境”](#兩個環境)
| | 測試環境 | 正式環境 |
| -------------------- | --------------------------------------------- | -------------------------------------------------- |
| API | `https://payment-api.testing.oen.tw` | `https://payment-api.oen.tw` |
| 結帳頁 | `https://{網域名稱}.testing.oen.tw/checkout/{id}` | `https://{網域名稱}.oen.tw/checkout/{id}` |
| CRM(產生 token、設定通知網址) | `https://{網域名稱}.testing.oen.tw/crm` | `https://{網域名稱}.oen.tw/crm` |
| token | 測試環境 CRM 產生 | 正式環境 CRM 產生 |
| 固定 IP 白名單 | 沒有 | **有**,要先申請,見[固定 IP 白名單](/developers/ip-allowlist/) |
| 付款通知 | **會實際送到你設定的網址** | 會 |
| 實際扣款 | 不會,連到收單機構的測試系統 | 會 |
* 兩個環境的 token 不能互用,用錯會回 401 `A0001`。
* 兩個環境的通知網址要分別在各自的 CRM 設定。
* 測試環境帳號與網域需要請應援開設,請商家聯絡業務人員。
## 各種頁面網址
[Section titled “各種頁面網址”](#各種頁面網址)
API 只回傳 `id`,結帳頁網址要自己組:
| API | 結帳頁路徑 | 有效時間 |
| ------------------------------------------------------------ | ------------------------------------ | ----- |
| [`POST /checkout`](/api/checkout/) | `/checkout/{id}` | 5 分鐘 |
| [`POST /checkout-subscription`](/api/checkout-subscription/) | `/checkout/subscription/{id}` | 5 分鐘 |
| [`POST /checkout-schedule`](/api/checkout-schedule/) | `/checkout/schedule/{id}` | 5 分鐘 |
| [`POST /checkout-token`](/api/checkout-token/) | `/checkout/subscription/create/{id}` | 10 分鐘 |
有效時間從呼叫 API 的那一刻起算,不是從消費者打開頁面起算。
## 測試付款
[Section titled “測試付款”](#測試付款)
測試環境連到收單機構的測試系統,不會真的扣款。
不要依賴舊文件的測試規則
舊版 Postman 文件寫「金額大於 100 元會成功、小於 100 元會失敗」,以及幾組固定的測試卡號。這些不是應援系統的規則,實際結果依收單機構的測試系統而定,請不要依賴。
* 各網域在測試環境使用的收單設定可能不同,可以用的測試卡號請向應援的聯絡人索取。
* 到期日請填未來的日期。結帳頁會直接擋下已過期的卡片。
* 要測試逾時,讓結帳頁停留超過 5 分鐘即可,頁面會導回 `failureUrl?payment_error=V0002`。
* 超商代碼、ATM 繳費完成的流程需要應援協助模擬,請聯絡聯絡人。
## 測試付款通知
[Section titled “測試付款通知”](#測試付款通知)
* 測試環境會把付款通知送到你在**測試環境 CRM** 設定的網址。
* 本機開發可以用 tunnel 工具取得公開的 https 網址。網址必須是 https、使用 443 port,而且可以從網際網路連到。
* 付款通知沒有「重送」按鈕,也沒有測試事件 API。要重複測試,請再建立一筆交易付款。
## 換到正式環境
[Section titled “換到正式環境”](#換到正式環境)
1. 在正式環境 CRM 產生 token,更新伺服器設定。
2. 在正式環境 CRM 設定通知網址。
3. 把 API 網址與結帳頁網址中的 `.testing` 拿掉。
4. 確認已完成[固定 IP 白名單](/developers/ip-allowlist/)申請。
5. 走一遍[上線檢查清單](/developers/go-live/)。
# 錯誤處理
> 怎麼讀錯誤回應、哪些錯誤可以重試,以及付款結果不明、逾時時如何避免重複扣款。
## 錯誤回應
[Section titled “錯誤回應”](#錯誤回應)
```json
{ "code": "V0001", "data": {}, "message": "PRODUCT_AMOUNT_NOT_MATCH" }
```
* 程式判斷請看 HTTP 狀態碼與 `code`。`message` 的文字可能調整,只用來記錄與除錯。
* 所有錯誤碼見[錯誤碼一覽](/api/error-codes/)。
## 可不可以重試
[Section titled “可不可以重試”](#可不可以重試)
| 你收到 | 可以重試嗎 | 怎麼做 |
| ------------------- | ----------- | -------------------------------------------------- |
| 400 `V0001` | 不行 | 依 `message` 修正參數 |
| 400 `V0002` | 不行 | 先查詢目前狀態 |
| 400 `T0001`~`T0005` | 不要用同一張卡立刻重試 | 請消費者換卡或聯絡發卡銀行 |
| 400 `K0001`、`V0003` | 不行 | 聯絡應援 |
| 401 `A0001` | 不行 | 檢查 token,見[驗證與 token](/developers/authentication/) |
| 403(沒有 `code`) | 不行 | IP 還沒綁定,見[固定 IP 白名單](/developers/ip-allowlist/) |
| 409 `C026` | **不行** | 付款結果不明,見下方 |
| 500 `F0001` | 看情況 | 先看 `message`,見下方 |
| 504、逾時、連線中斷 | **先查再說** | 請求可能已經成功,見下方 |
## 付款結果不明時,不要再扣一次
[Section titled “付款結果不明時,不要再扣一次”](#付款結果不明時不要再扣一次)
Payment API 沒有 Idempotency-Key
同樣的請求送兩次,就會建立兩筆交易;用 token 扣款時就是**扣兩次**。所以結果不明時,絕對不要直接重送。
### 409 `C026`
[Section titled “409 C026”](#409-c026)
回應是:
```json
{ "code": "C026", "data": {}, "message": "PAYMENT_STATUS_UNKNOWN", "retryable": false }
```
扣款可能已經發生,只是結果還沒確定。交易會停在 `charging`:
1. 不要重送,也不要建立新的訂單再扣一次。
2. 稍後用[訂單編號查詢](/api/list-order-transactions/)確認交易狀態。
3. 一段時間後仍是 `charging`,請帶交易編號聯絡應援。
### 504、逾時、連線中斷
[Section titled “504、逾時、連線中斷”](#504逾時連線中斷)
API 最多處理 29 秒,超過會回 504,但後面的處理可能還在進行,甚至已經成功。
1. 用[訂單編號查詢](/api/list-order-transactions/),看有沒有剛剛那筆交易。
2. 有,而且是 `charged` 或 `claimed`:當作成功,不要重送。
3. 有,而且是 `charging`:等一下再查。
4. 沒有,或是 `failed`:才可以重送。
### 500 `F0001`
[Section titled “500 F0001”](#500-f0001)
| `message` | 原因 | 怎麼做 |
| -------------------------------- | ------------------------------ | ------------------------- |
| `Invalid date` | 查詢列表的 `start`、`end` 格式錯誤,或只帶一個 | 修正參數 |
| `Pagination parse failed` | `page` 分頁標記無效 | 用上一頁回傳的原值 |
| `Current charged amount too low` | 退款時,網域尚未撥款的金額低於 200 元 | 聯絡應援 |
| JSON 解析錯誤 | body 不是合法的 JSON | 修正 body |
| 其他 | 系統錯誤 | 查詢類可以稍後重試;建立交易、退款類請先查詢再決定 |
## 欄位驗證錯誤
[Section titled “欄位驗證錯誤”](#欄位驗證錯誤)
* 驗證失敗回 400 `V0001`,`message` 會列出所有不符合的規則,例如 `must have required property 'productDetails'; must be >= 1`。
* `message` 不一定有欄位名稱,請對照 API 參考的欄位表逐一檢查。
* 規格裡沒有的欄位會被忽略,不會報錯。欄位名稱拼錯時,常見的現象是「送了但沒效果」或「必填欄位缺少」。
## 常見錯誤
[Section titled “常見錯誤”](#常見錯誤)
| message | 原因 |
| ---------------------------------------------- | ------------------------------------------------ |
| `must have required property 'productDetails'` | 沒帶商品明細。所有建立交易的 API 都要帶 |
| `PRODUCT_AMOUNT_NOT_MATCH` | 品項「數量 × 單價」的合計不等於 `amount` |
| `USER_NAME_AND_EMAIL_REQUIRED` | 網域有開通電子發票,但沒帶 `userName` 或 `userEmail` |
| `INVALID_PARAMS` | `merchantId` 和 token 的網域不同;或查詢的交易不屬於你的網域 |
| `PAYMENT_SERVICE_NOT_ACTIVATE` | 網域的金流服務還沒開通 |
| `LINE_PAY_NOT_ACTIVE` | 帶了 `linePay`,但 LINE Pay 還沒開通 |
| `START_DATE_MUST_GREATER_THAN_TODAY` | `startDate` 早於今天(用 token 建立定期定額時,今天也不行) |
| `must match format "uri"` | `successUrl` 或 `failureUrl` 不是完整網址,少了 `https://` |
## 回報問題時
[Section titled “回報問題時”](#回報問題時)
請提供:網域名稱、環境(測試或正式)、呼叫時間、端點、交易編號或 `orderId`,以及完整的錯誤回應。**不要附上 token。**
# 上線檢查清單
> 切換到正式環境前逐項確認:token、固定 IP、付款通知、結果判斷、重試與對帳。
上線前逐項確認。每一項都附上說明頁。
## 帳號與設定
[Section titled “帳號與設定”](#帳號與設定)
* [ ] 正式環境的 token 已產生,只放在伺服器的環境變數或密鑰管理服務,沒有進版本控制。([驗證與 token](/developers/authentication/))
* [ ] 伺服器的對外 IP 已綁定,從正式環境呼叫查詢 API 不會回 403。([固定 IP 白名單](/developers/ip-allowlist/))
* [ ] 正式環境 CRM 已設定「交易資料回傳網址位置」,是 https、443 port、可以從外部連到。([付款通知](/developers/webhooks/))
* [ ] API 與結帳頁網址已經拿掉 `.testing`。
* [ ] 要用的付款方式都已開通,結帳頁實際出現的方式符合預期(信用卡一定會出現)。([付款方式](/developers/payment-methods/))
* [ ] 需要退款、取消通知的話,已請應援開啟「Webhook 新版事件」。
## 建立交易
[Section titled “建立交易”](#建立交易)
* [ ] 每一筆都帶 `productDetails`,品項合計等於 `amount`。
* [ ] 消費者按下付款時才建立結帳,建立後立即導轉(結帳頁 5 分鐘內有效)。
* [ ] `successUrl`、`failureUrl` 用路徑或參數帶上自己的訂單編號,`failureUrl` 沒有自帶 `?`。
* [ ] 網域有開通電子發票時,有帶 `userName` 與 `userEmail`。
## 判斷結果
[Section titled “判斷結果”](#判斷結果)
* [ ] 出貨以回查結果為準,不是導回網址,也不是付款通知的內容。
* [ ] 收到付款通知後用 `id` 呼叫 `GET /transactions/{id}` 回查,並比對 `orderId` 與金額。
* [ ] `charged` 與 `claimed` 都視為付款成功。
* [ ] 收到 `charging`(超商、ATM、LINE Pay)時不出貨,等下一則通知。
* [ ] 付款通知的接收端在 10 秒內回 200,耗時工作放到背景;同一則通知重複收到也不會重複出貨。
## 重試與對帳
[Section titled “重試與對帳”](#重試與對帳)
* [ ] 逾時、504、409 `C026` 時不直接重送,先用訂單編號查詢。([錯誤處理](/developers/errors/))
* [ ] 有定期工作處理「超過 15 分鐘還沒結果」的訂單,補救漏掉的付款通知。
* [ ] 同一個 `orderId` 有兩筆以上成功時,會通知人員處理。
* [ ] 每天和應援的交易資料對帳一次。([查詢與對帳](/developers/querying/))
## 定期定額與存卡(有用到才需要)
[Section titled “定期定額與存卡(有用到才需要)”](#定期定額與存卡有用到才需要)
* [ ] 已在 CRM 開啟「定期交易重試機制」,或有機制處理一期失敗後整個停止的情況。([定期定額](/developers/subscriptions/))
* [ ] 消費者提出取消時,會立即呼叫取消 API。
* [ ] token 通知的接收端穩定,並用 `customId` 對應會員;token 只存在伺服器。([存卡與後續扣款](/developers/saved-cards/))
* [ ] 用 token 扣款逾時時,先查詢再決定是否重送。
## 退款
[Section titled “退款”](#退款)
* [ ] 退款前確認交易還沒退過,金額一次決定好。
* [ ] 超商代碼、ATM 退款會帶退款帳戶。
* [ ] 有開發票的交易,退款會帶 `productDetails`。
## 最後測試
[Section titled “最後測試”](#最後測試)
* [ ] 在測試環境跑過:付款成功、付款失敗、結帳頁逾時、消費者放棄付款、退款。
* [ ] 上線後用正式環境跑一筆小額交易並退款,確認付款通知、回查與退款都正常。
# 固定 IP 白名單
> 正式環境的 Payment API 只接受已綁定的 IP。要準備什麼、怎麼申請、被擋下時會看到什麼。
正式環境一定要先綁定
正式環境 `payment-api.oen.tw` 只接受已綁定的 IP,其他來源的請求一律回 HTTP 403。**測試環境沒有這個限制**,所以常見的狀況是「測試環境都正常,一上線就 403」。
## 哪些會受影響
[Section titled “哪些會受影響”](#哪些會受影響)
| | 要綁定 IP 嗎 |
| ------------------------------------ | -------- |
| 你的伺服器呼叫 `payment-api.oen.tw` | **要** |
| 你的伺服器呼叫 `payment-api.testing.oen.tw` | 不用 |
| 消費者打開應援結帳頁付款 | 不用 |
| 應援送付款通知給你 | 不用(方向相反) |
透過已和應援串接好的合作平台收款的商家,不需要申請。
## 準備固定的對外 IP
[Section titled “準備固定的對外 IP”](#準備固定的對外-ip)
你的伺服器呼叫 API 時,對外使用的 IP 必須固定:
* 自己的主機或 VPS:通常就是主機的公開 IP。
* 雲端服務(容器、Serverless、自動擴展的主機):對外 IP 通常會變動,請設定 NAT Gateway 或固定出口 IP,讓所有呼叫都從固定的 IP 出去。
* 有多台伺服器、多個區域或備援機房時,每一個出口 IP 都要申請。
要確認伺服器實際的對外 IP,可以在伺服器上執行 `curl https://checkip.amazonaws.com`。
## 申請
[Section titled “申請”](#申請)
1. 準備:網域名稱、要綁定的 IP 清單。
2. 請商家聯絡應援客服或業務人員提出申請。
3. 應援完成設定後會通知你。
4. 從伺服器呼叫一次查詢類的 API(例如[查詢交易列表](/api/list-transactions/)),確認不再回 403。
更換主機或出口 IP 時,請**先**申請新的 IP,確認生效後再切換,避免中斷。
## 被擋下時會看到什麼
[Section titled “被擋下時會看到什麼”](#被擋下時會看到什麼)
```http
HTTP/1.1 403 Forbidden
{"message":"Forbidden"}
```
* body 只有 `message`(通常是 `Forbidden`),**沒有** `code` 欄位,和 API 自己的錯誤格式不同。
* 用這點就能分辨:有 `code: "A0001"` 是 token 的問題;沒有 `code` 的 403 是 IP 還沒綁定。
## 付款通知的來源 IP
[Section titled “付款通知的來源 IP”](#付款通知的來源-ip)
你的防火牆需要限制來源時,請向應援索取付款通知的來源 IP。付款通知從固定的出口送出。
# 單次付款
> 用 POST /checkout 建立結帳頁、處理 5 分鐘時效與重新付款、判斷付款結果、帶入發票資訊,以及各種付款方式的流程差異。
## 流程
[Section titled “流程”](#流程)
1. 消費者在你的網站按下「付款」。
2. 你的伺服器呼叫 [`POST /checkout`](/api/checkout/),拿到 `id` 與 `transactionHid`,存進訂單。
3. 把消費者導到 `https://{網域名稱}.oen.tw/checkout/{id}`。
4. 消費者在應援結帳頁選擇付款方式並付款。
5. 應援送出[付款通知](/developers/webhooks/)到你的伺服器,你用 [`GET /transactions/{id}`](/api/get-transaction/) 回查後更新訂單。
6. 消費者被導回 `successUrl` 或 `failureUrl`。
## 建立結帳的重點
[Section titled “建立結帳的重點”](#建立結帳的重點)
* **`productDetails` 必填**,各品項「數量 × 單價」的合計必須等於 `amount`。有折扣或運費時,請調整品項(例如把折扣攤進單價、把運費列成一個品項),讓合計等於實付金額。
* **金額是新台幣整數**。`currency` 可以不帶,預設就是 `TWD`。
* **網址自己組**:回應只有 `id`,結帳頁是 `https://{網域名稱}.oen.tw/checkout/{id}`,測試環境是 `{網域名稱}.testing.oen.tw`。
* **沒有的欄位會被忽略**:例如 `webhookUrl`、`cancelUrl` 都不是 Payment API 的欄位,送了也不會有效果。付款通知網址在 CRM 設定。
完整欄位見 [API 參考](/api/checkout/)。
## 結帳頁 5 分鐘內有效
[Section titled “結帳頁 5 分鐘內有效”](#結帳頁-5-分鐘內有效)
結帳頁從你呼叫 `POST /checkout` 的那一刻起算,**5 分鐘**內有效:
* 消費者停在結帳頁、倒數到 0 時,會看到「付款時限已過」,8 秒後被導回 `failureUrl?payment_error=V0002`。
* 過期後才打開連結,會看到「此付款連結資訊已過期」,**不會**導回你的網站。
所以請在消費者真的要付款時才建立,建立後立刻導過去。不要先建好連結再用 email 或訊息寄給消費者。
5 分鐘後結果仍可能改變
5 分鐘只限制「打開結帳頁」。消費者在時限內送出付款後,3D 驗證、LINE Pay 確認、超商或 ATM 繳費的結果,都可能在 5 分鐘之後才確定。不要在 5 分鐘到的時候就把訂單判定為失敗、之後又不接受成功的通知。
## 消費者要重新付款
[Section titled “消費者要重新付款”](#消費者要重新付款)
消費者付款失敗、逾時或改變心意時,重新呼叫 `POST /checkout` 建立新的交易即可:
* **同一個 `orderId` 可以建立多筆交易**,應援不會擋。
* 每一筆都是獨立的交易,有自己的 `id`。
* 極少數情況下,消費者可能在兩個結帳頁都完成付款。請用 [`GET /order/{orderId}/transactions`](/api/list-order-transactions/) 檢查同一個訂單有幾筆成功,多的那筆請[退款](/developers/refunds/)。
## 消費者放棄付款
[Section titled “消費者放棄付款”](#消費者放棄付款)
消費者沒有付款、直接離開時,**應援不會送任何通知**,交易會一直停在 `initiated`。請在你的系統自行處理,例如:
* 建立後 30 分鐘仍沒有結果的訂單,先用[訂單編號查詢](/api/list-order-transactions/)確認沒有成功或處理中的交易,再把訂單標為未付款。
* 查到 `charging`(超商、ATM 等待繳費,或 LINE Pay 等待確認)時,繼續等待。
## 判斷付款結果
[Section titled “判斷付款結果”](#判斷付款結果)
| 你看到的 | 意思 | 怎麼做 |
| -------------------------------- | -------------------------------- | -------- |
| `status` 是 `charged` 或 `claimed` | 付款成功 | 出貨 |
| `status` 是 `charging` | 處理中:超商、ATM 已取號等待繳費;LINE Pay 等待確認 | 等下一則通知 |
| `status` 是 `failed` | 付款失敗或逾期未繳 | 讓消費者重新付款 |
| `status` 是 `initiated` | 消費者還沒送出付款 | 等待,或視為放棄 |
一律以[回查](/api/get-transaction/)的結果為準,不要只看付款通知或導回網址。
## 導回網址
[Section titled “導回網址”](#導回網址)
* **成功**:導回 `successUrl`,**不帶任何參數**。請在網址放自己的訂單編號,例如 `?order=A001`,頁面再到你的後端查訂單狀態。
* **失敗**:導回 `failureUrl`,加上 `payment_error`,可能的值見[錯誤碼一覽](/api/error-codes/#%E7%B5%90%E5%B8%B3%E9%A0%81%E5%B0%8E%E5%9B%9E%E7%9A%84-payment_error)。`#` 之後的片段會被拿掉。
* **超商代碼、ATM**:消費者取號後停在應援的繳費資訊頁,按「返回網站」才回到 `successUrl`。
* **LINE Pay**:失敗或取消時導回 `failureUrl`,不帶 `payment_error`。
failureUrl 不要自帶參數
部分 3D 驗證失敗的情況,會直接在 `failureUrl` 後面接上 `?payment_error=T0001`。`failureUrl` 原本就有 `?` 時,網址會變成兩個 `?`。請讓 `failureUrl` 用路徑帶訂單編號,例如 `https://shop.example.com/payment/failure/A001`。
## 付款方式
[Section titled “付款方式”](#付款方式)
用 `allowedPaymentMethods` 加開超商代碼、LINE Pay、ATM。**信用卡一定會出現**,Apple Pay 條件符合時自動出現。詳見[付款方式](/developers/payment-methods/)。
## 3D 驗證
[Section titled “3D 驗證”](#3d-驗證)
* `use3d: true` 時,信用卡付款會走 3D 驗證。預設是 `false`。
* 應援可以把你的網域設定為一律 3D,這時不管帶什麼都會走 3D。
* 3D 驗證開始後 10 分鐘沒有完成,交易會變成失敗,並送出 `message` 為 `3DS_ABANDONED` 的付款通知。
* Apple Pay 不走 3D 驗證。
## 電子發票
[Section titled “電子發票”](#電子發票)
網域開通「應援代開電子發票」後,付款成功會自動開立發票:
* `userName` 與 `userEmail` 變成必填,發票通知寄到 `userEmail`。
* 發票品項來自 `productDetails`。
* 要開手機條碼、自然人憑證或公司戶發票,帶 `invoiceInfo`:
```json
{
"invoiceInfo": { "invoiceType": "cloud", "carrierType": "3J0002", "carrierId": "/ABC+123" }
}
```
```json
{
"invoiceInfo": { "invoiceType": "company", "buyerIdentifier": "12345675", "buyerName": "應援範例股份有限公司" }
}
```
手機條碼會即時向財政部驗證,不存在的條碼會回 400 `V0001`(`INVALID_CARRIER_ID`);統一編號會檢查檢查碼(`INVALID_TAX_ID_NUMBER`)。沒帶 `invoiceInfo` 時開立雲端發票。
## 帶上你自己的資料
[Section titled “帶上你自己的資料”](#帶上你自己的資料)
* `customId`:原樣出現在付款通知與查詢結果,適合放購物車編號等資料。
* `userId`:你系統裡的會員編號,會出現在查詢結果與 CRM。
* `note`:備註,會出現在 CRM 金流明細。
# 付款方式
> 結帳頁支援的付款方式、開通條件、金額範圍與繳費期限,以及 allowedPaymentMethods 的實際效果。
## 支援的付款方式
[Section titled “支援的付款方式”](#支援的付款方式)
| 付款方式 | `allowedPaymentMethods` 的值 | 開通 | 金額範圍(新台幣) | 付款期限 |
| --------- | -------------------------- | ------------------------------- | ------------------------ | --------- |
| 信用卡 | `card`(一定會出現) | 隨金流開通 | 1 以上;200,000 以上要另外開通高額交易 | — |
| Apple Pay | 不用指定,自動出現 | 隨信用卡 | 同信用卡 | — |
| 超商代碼 | `cvs` | 請應援開通 | 80 到 20,000 | 取號後 48 小時 |
| ATM 虛擬帳號 | `atm` | 請應援開通(需要藍新金流) | 100 到 20,000 | 3 天 |
| LINE Pay | `linePay` | 自備 LINE Pay 商店帳號,請應援開通後在 CRM 設定 | 30 到 200,000 | 20 分鐘 |
* 不支援 Google Pay、信用卡分期。
* 幣別只支援新台幣。
* 金額不在範圍內的付款方式,結帳頁不會顯示。
* 超商代碼可以繳費的超商依網域設定而定:全家,或 7-ELEVEN、全家、OK、萊爾富。
* 只有[單次付款](/api/checkout/)可以選擇付款方式。定期定額與存卡只支援信用卡。
## allowedPaymentMethods 的實際效果
[Section titled “allowedPaymentMethods 的實際效果”](#allowedpaymentmethods-的實際效果)
這是「加開」,不是「只開」
信用卡一定會出現在結帳頁,目前沒辦法只開超商代碼或只開 LINE Pay。
| 你傳入 | 結帳頁會出現 |
| -------------------- | -------------------------------------------- |
| 不傳,或 `[]` | 信用卡(+ Apple Pay) |
| `["cvs"]` | 信用卡、超商代碼(+ Apple Pay) |
| `["linePay"]` | 信用卡、LINE Pay(+ Apple Pay) |
| `["atm"]` | 信用卡、ATM(+ Apple Pay)。網域沒有設定藍新 ATM 時,ATM 不會出現 |
| `["cvs", "linePay"]` | 信用卡、超商代碼、LINE Pay(+ Apple Pay) |
* Apple Pay 只在支援的裝置與瀏覽器(例如 iPhone 的 Safari)顯示。
* 帶 `linePay` 但網域的 LINE Pay 還沒開通時,建立結帳會直接回 400 `V0001`(`LINE_PAY_NOT_ACTIVE`)。
* 帶 `cvs` 但網域沒有開通超商代碼時,建立結帳會成功,但消費者選超商時會失敗。請先確認開通狀態。
* 從 v10.3.0 起,結帳頁會照你傳入的清單顯示並把關。
## 各付款方式的流程差異
[Section titled “各付款方式的流程差異”](#各付款方式的流程差異)
### 信用卡、Apple Pay
[Section titled “信用卡、Apple Pay”](#信用卡apple-pay)
消費者當場付款,成功後立刻導回 `successUrl`,付款通知的 `status` 是 `charged` 或 `claimed`。
### 超商代碼、ATM
[Section titled “超商代碼、ATM”](#超商代碼atm)
警告
這兩種方式會收到**兩則**付款通知,而且 `id` 相同:
1. 取號時:`status: charging`,沒有 `success` 欄位,`paymentInfo` 裡有繳費代碼或虛擬帳號。
2. 繳費後:`status: charged`、`success: true`;逾期未繳則是 `status: failed`、`message: PAYMENT_EXPIRED`。
收到第一則時不要出貨。
* 取號後,消費者停在應援的繳費資訊頁,按「返回網站」才回到 `successUrl`。建議你也在訂單頁顯示繳費代碼與期限(取自付款通知的 `paymentInfo`)。
* 退款要帶消費者的銀行帳戶,由應援匯款,見[退款](/developers/refunds/)。尚未繳費的超商代碼可以用退款 API 直接取消。
### LINE Pay
[Section titled “LINE Pay”](#line-pay)
* 消費者被導到 LINE Pay 付款,確認前會先收到 `status: charging` 的通知,確認後再收到最終結果。
* 20 分鐘內沒有完成,交易會變成失敗(`PAYMENT_EXPIRED`)。
* 付款失敗或取消時導回 `failureUrl`,不帶 `payment_error`。
* LINE Pay 的款項由 LINE Pay 直接撥給商家,付款成功的狀態是 `claimed`。
## 商家要先開通
[Section titled “商家要先開通”](#商家要先開通)
各付款方式的開通方式,請把[開通金流與付款方式](/merchant/activate/)轉給商家。
# 查詢與對帳
> 三種查詢交易的方式怎麼選、列表的範圍與分頁,以及建議的每日對帳流程。
## 三種查詢
[Section titled “三種查詢”](#三種查詢)
| 想知道 | 用這支 | 備註 |
| ----------- | -------------------------------------------------------------------- | ------------------------------------ |
| 某一筆交易的最新狀態 | [`GET /transactions/{id}`](/api/get-transaction/) | 用 27 字元的 `id` 查,才會有 `productDetails` |
| 某個訂單的所有付款嘗試 | [`GET /order/{orderId}/transactions`](/api/list-order-transactions/) | 只有 API 交易,含失敗與定期定額各期;一次回傳全部 |
| 一段期間的所有款項 | [`GET /transactions?start=&end=`](/api/list-transactions/) | 每頁 50 筆,由新到舊;**包含非 API 的款項** |
| 定期定額的狀態 | [`GET /subscriptions/{id}`](/api/get-subscription/) | 用 `S` 開頭的編號或內部 id |
## 交易列表的範圍
[Section titled “交易列表的範圍”](#交易列表的範圍)
列表包含網域的所有款項
`GET /transactions` 列出的是整個網域的款項,除了 API 建立的交易,也包含應援商店訂單、捐款等其他收款,以及撥款後退款產生的負數調整款項。這些款項的 `id` 是 `C` 開頭。只要 API 交易時,請用 `orderId` 比對你的訂單,或篩選 `id` 為 `P` 開頭的項目。
### 期間與分頁
[Section titled “期間與分頁”](#期間與分頁)
* `start` 與 `end` 要一起帶,以台北時間的整天計算:`start` 當天 00:00 到 `end` 當天 23:59:59。
* 日期可以寫 `2026-09-01`,或 Unix **毫秒**時間戳。只帶一個或格式錯誤會回 500 `F0001`(`Invalid date`)。
* 依交易**建立時間**篩選,不是付款時間。
* 回應的 `page` 不是 `null` 時,把它原封不動放進下一次請求的 `?page=`,直到 `page` 是 `null`。
```js
async function listAll(start, end) {
const all = [];
let page = null;
do {
const qs = new URLSearchParams({ start, end, ...(page ? { page } : {}) });
const res = await fetch(`https://payment-api.oen.tw/transactions?${qs}`, {
headers: { Authorization: `Bearer ${process.env.OEN_API_TOKEN}` },
});
const result = await res.json();
if (result.code !== "S0000") throw new Error(`${result.code} ${result.message}`);
all.push(...result.data.transactions);
page = result.data.page;
} while (page);
return all;
}
```
## 建議的對帳流程
[Section titled “建議的對帳流程”](#建議的對帳流程)
1. **每 15 分鐘**:處理還沒有結果的訂單(做法見[沒收到通知時](/developers/webhooks/#%E6%B2%92%E6%94%B6%E5%88%B0%E9%80%9A%E7%9F%A5%E6%99%82))。
2. **每天**:用 `GET /transactions` 列出前一天建立的款項,只取 `P` 開頭的項目,和你的訂單逐筆比對:
* 你的系統是「已付款」,應援是 `failed` 或 `initiated`:查明原因,不要出貨。
* 應援是 `charged` 或 `claimed`,你的系統不是「已付款」:補標。
* 同一個 `orderId` 有兩筆以上成功:[退款](/developers/refunds/)多的那筆。
3. **每次撥款後**:到 CRM「撥款列表」匯出撥款細項,核對手續費與撥款金額。見[查詢交易與對帳](/merchant/transactions/)。
## 查詢的注意事項
[Section titled “查詢的注意事項”](#查詢的注意事項)
* 請不要高頻率地輪詢單筆交易。付款通知到了再查,或用上面的定期工作即可。
* 交易剛完成的極短時間內,查到的可能還是舊狀態,稍等幾秒再查。
* 用 `P` 開頭的交易編號查詢單筆時,回應不含 `productDetails` 與 `numberOfPeriods`。
* 回應裡沒有 `currency` 欄位,Payment API 的交易都是新台幣。
# 退款
> 用 API 退款的規則:每筆只能退一次、各付款方式的差異、超商與 ATM 的退款帳戶、發票折讓,以及退款失敗時怎麼處理。
用 [`POST /refunds/{transactionHid}`](/api/refund/) 退款。路徑要用 `P` 開頭的交易編號,不是 27 字元的 `id`。
每筆交易只能退一次
不論用 API 或 CRM,每筆交易都只能成功退款一次。部分退款之後,剩下的金額不能再退。請一次決定好金額。
## 可以退的交易
[Section titled “可以退的交易”](#可以退的交易)
交易狀態必須是 `charged` 或 `claimed`,而且還沒退過款。
| 原付款方式 | 怎麼退 | 要帶 `remitInfo` 嗎 | 退款後的狀態 |
| ------------- | ------------- | ---------------- | ------------------------------------- |
| 信用卡、Apple Pay | 即時退回卡片 | 不用 | `refunded`;已撥款的是 `refundedPostPayout` |
| LINE Pay | 即時退回 LINE Pay | 不用 | `refunded` |
| 超商代碼、ATM(已繳費) | 由應援匯款到消費者的帳戶 | **要** | `refunding`,匯款完成後更新 |
| 超商代碼(還沒繳費) | 取消繳費代碼 | 不用 | `cancelled` |
* `amount` 不帶就是全額退款;有帶就是部分退款,不能大於原金額。
* 退款成功時,回應的 `status` 已經是新的狀態,`refundAmount` 是這次退款的金額(超商、ATM 要等匯款完成才更新)。
## 超商代碼與 ATM
[Section titled “超商代碼與 ATM”](#超商代碼與-atm)
已繳費的交易要帶消費者的銀行帳戶:
```json
{
"merchantId": "ming",
"reason": "商品缺貨",
"remitInfo": {
"bankCode": "004",
"bankName": "臺灣銀行",
"branchCode": "0037",
"branchName": "營業部",
"account": "12345678901234",
"accountName": "王小明"
}
}
```
* 6 個欄位都必填;`account` 是 8 到 14 字元。
* 退款會停在 `refunding`,由應援處理匯款。
## 有開電子發票的交易
[Section titled “有開電子發票的交易”](#有開電子發票的交易)
原交易有開電子發票時:
* **`productDetails` 必填**,品項合計必須等於這次的退款金額,用來開立折讓。
* 信用卡與 LINE Pay 退款成功後,系統會自動開立折讓。
* 發票還沒開出就全額退款時,系統直接取消待開的發票。
```json
{
"merchantId": "ming",
"amount": 600,
"reason": "消費者取消一包",
"productDetails": [
{ "productionCode": "SKU-001", "description": "手沖咖啡豆 200g", "quantity": 1, "unit": "包", "unitPrice": 600 }
]
}
```
## 退款失敗
[Section titled “退款失敗”](#退款失敗)
| 你收到 | 意思 | 怎麼做 |
| -------------------------------------------- | ----------------------------- | ------------------------------------------- |
| 400 `V0002` | 交易狀態不能退款:還沒付款、已經退過,或正在處理另一筆退款 | 用[查詢交易明細](/api/get-transaction/)確認狀態 |
| 400 `R0001` | 收單機構拒絕退款 | 先查詢交易狀態。之後重試都回 `V0002` 時,代表退款結果需要人工確認,請聯絡應援 |
| 400 `V0001` `REMITTANCE_INFO_REQUIRED` | 已繳費的超商代碼或 ATM 沒帶 `remitInfo` | 補上退款帳戶 |
| 400 `V0001` `PRODUCT_DETAILS_REQUIRED` | 有開發票的交易沒帶 `productDetails` | 補上折讓品項 |
| 500 `F0001` `Current charged amount too low` | 你的網域尚未撥款的已收款金額低於 200 元,暫時無法退款 | 請聯絡應援 |
| 逾時、500 其他訊息 | 結果不明 | **不要直接重送**。先查詢交易的 `status` 與 `refundAmount` |
## 已撥款的交易
[Section titled “已撥款的交易”](#已撥款的交易)
款項已經撥給商家後才退款,交易會變成 `refundedPostPayout`,退款金額與退款手續費會從下一次撥款扣除。
## 退款通知
[Section titled “退款通知”](#退款通知)
退款不會送 `charge` 通知。要收到退款通知(`purpose: refund`),請應援為你的網域開啟「Webhook 新版事件」,欄位見[付款通知內容](/api/objects/webhook/#refund%E9%9C%80%E9%96%8B%E5%95%9F)。
## 在 CRM 退款
[Section titled “在 CRM 退款”](#在-crm-退款)
商家也可以在 CRM 的金流明細退款,規則相同,見[商家指南的退款說明](/merchant/refunds/)。
# 存卡與後續扣款
> 讓消費者在綁卡頁完成 3D 驗證,透過付款通知拿到 token,之後由你的伺服器用 token 扣款或建立定期定額。
存卡適合「金額或時間由你決定」的情境,例如儲值、按用量計費、一鍵回購。消費者只需要綁一次卡,之後由你的伺服器用 token 扣款,消費者不必在場。
## 流程
[Section titled “流程”](#流程)
1. 你的伺服器呼叫 [`POST /checkout-token`](/api/checkout-token/),拿到 `id`。
2. 把消費者導到 `https://{網域名稱}.oen.tw/checkout/subscription/create/{id}`。**10 分鐘內有效。**
3. 消費者輸入卡片並完成 3D 驗證。
4. 應援送出 `purpose` 為 `token` 的[付款通知](/api/objects/webhook/#token),裡面有 `token`。把它存進這位會員的資料。
5. 消費者被導回 `successUrl`(**不帶 token**)。
6. 之後要扣款時,呼叫 [`POST /token/transactions`](/api/token-transactions/) 或 [`POST /token/subscriptions`](/api/token-subscriptions/)。
token 只能從付款通知拿到
* 導回網址不帶 token,也**沒有 API 可以查詢 token**。
* 付款通知最多送 3 次(間隔約 2 秒、4 秒),都失敗就不會再送,這個 token 就拿不回來,只能請消費者重新綁卡。
* 請確認接收端穩定,並用 `customId` 帶上會員編號,收到通知時才知道是誰的卡。
## 綁卡頁
[Section titled “綁卡頁”](#綁卡頁)
* 消費者一定要填 Email。建立時帶 `payerEmail` 可以預先填好。
* 部分收單機構綁卡時會**先授權新台幣 1 元再立即退回**,頁面會事先告知消費者,也可能要求填寫英文持卡人姓名。
* 逾時(10 分鐘)後導回 `failureUrl?payment_error=Y003`。
* 3D 驗證開始後 10 分鐘沒完成,會送出 `success: false`、`message: 3DS_ABANDONED` 的通知。
## token 的特性
[Section titled “token 的特性”](#token-的特性)
| 特性 | 說明 |
| -------- | --------------------------------- |
| 有效期限 | 沒有期限。卡片本身過期後,扣款會被發卡銀行拒絕 |
| 同一張卡再綁一次 | 會得到**新的** token,舊的仍然可以用 |
| 刪除 | 沒有刪除 API。會員要求移除卡片時,請在你的系統刪除 token |
| 換卡 | 沒有更新卡片的 API。請消費者重新綁卡,換成新的 token |
| 保存 | token 可以直接扣款,請當成密碼一樣保存,不要傳到前端 |
## 用 token 扣款
[Section titled “用 token 扣款”](#用-token-扣款)
```bash
curl -X POST "https://payment-api.testing.oen.tw/token/transactions" \
-H "Authorization: Bearer $OEN_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"merchantId": "ming",
"amount": 1200,
"token": "2HhnhWm8Qe0sPz4LbXv9KdYt1Ra",
"orderId": "A20260928003",
"productDetails": [
{ "productionCode": "SKU-001", "description": "手沖咖啡豆 200g", "quantity": 2, "unit": "包", "unitPrice": 600 }
]
}'
```
* 不走 3D 驗證,**結果就是 API 回應**,不會送付款通知。
* 成功回傳 `id`(`P` 開頭的交易編號)與 `authCode`。
* 失敗回 400 與 `T0001`~`T0005`。失敗的交易也會建立,可以用訂單編號查到。
* 只能是新台幣,`amount` 必須是整數。
* 單筆 200,000 元以上需要網域開通高額交易,否則回 `V0003`。
不要直接重送
Payment API 沒有防重複扣款的機制:同樣的請求送兩次,就會扣兩次。遇到下列情況時,**先用[訂單編號查詢](/api/list-order-transactions/)確認**,再決定要不要重送:
* 請求逾時,或收到 HTTP 504、500。
* 收到 409 `C026`(付款結果不明)。這時請不要重送,稍後再查詢;久未確定請聯絡應援。
## 用 token 建立定期定額
[Section titled “用 token 建立定期定額”](#用-token-建立定期定額)
[`POST /token/subscriptions`](/api/token-subscriptions/) 用 token 建立定期定額,之後由應援自動扣款:
* 不帶 `startDate`:當下扣第一期,結果以回應為準;第一期失敗時定期定額不會成立。
* 帶未來日期:只建立排程(狀態 `scheduled`),**不會先驗證 token**,到期才扣款。
扣款時間與失敗處理見[定期定額](/developers/subscriptions/)。
# 定期定額
> 三種建立定期定額的方式怎麼選、每期何時扣款、扣款失敗的處理與重扣設定、取消與查詢,以及要注意的日期規則。
定期定額是「消費者同意一次,之後由應援依週期自動扣款」。只支援信用卡,扣款週期以月為單位。
## 三種建立方式
[Section titled “三種建立方式”](#三種建立方式)
| | [建立定期定額](/api/checkout-subscription/) | [建立預約定期定額](/api/checkout-schedule/) | [用 token 建立定期定額](/api/token-subscriptions/) |
| ----------- | ------------------------------------- | ----------------------------------- | ------------------------------------------- |
| 消費者要做的事 | 在結帳頁付第一期 | 在結帳頁綁卡(首期是今天時同時付款) | 不用,之前已經[綁卡](/developers/saved-cards/) |
| 第一期何時扣 | 消費者付款當下 | `startDate`:今天就是付款當下,未來日期就在當天扣 | 不帶 `startDate` 就在呼叫 API 當下;帶未來日期就在當天扣 |
| 扣款間隔 | 固定每 1 個月 | 1 到 12 個月(`paymentInterval`) | 1 到 12 個月 |
| `startDate` | 不支援 | 今天到 12 個月內 | **必須晚於今天**,12 個月內 |
| 回應 | `id`、`transactionHid` | `id`、`subscriptionHid` | `subscriptionId`、`transactionId`、`authCode` |
| 適合 | 最簡單的月費 | 季繳、年繳、下個月才開始扣 | 已經有存卡的會員 |
* `numberOfPeriods` 是總期數,最少 2 期;不帶就是不限期,直到取消。
* 每期金額固定,就是建立時的 `amount`。要改金額,請取消後重新建立。
* 定期定額編號是 `S` 開頭,查詢與取消都用它。用「建立定期定額」時,第一期付款成功後,付款通知與查詢結果的 `subscriptionId` 就是這個編號。
## 每期什麼時候扣款
[Section titled “每期什麼時候扣款”](#每期什麼時候扣款)
* 應援每天**台北時間上午 9 點**執行扣款。
* 下一期日期 = 上一期實際扣款的日期 + 間隔月數。
* 每次扣款前 3 天,應援會寄扣款提醒信給消費者(有 Email 時)。
* 每一期的結果都會送出 `purpose` 為 `charge`、`action` 為 `subscription` 的[付款通知](/api/objects/webhook/)。
日期會往前或往後移
* 起始日是 29、30、31 日時,遇到較短的月份會移到較早的日期,之後就維持在那一天。例如 1/31 起始,之後可能是 2/28、3/28。
* 某一期扣款失敗、重扣成功後,之後各期會以重扣成功的日期往後推。
如果你的系統需要固定在某一天扣款,請把起始日設在每月 28 日以前。以 [`GET /subscriptions/{id}`](/api/get-subscription/) 的 `nextChargeAt` 為準。
## 扣款失敗
[Section titled “扣款失敗”](#扣款失敗)
預設不重扣:一期失敗,整個定期定額就停止
重扣機制**預設是關閉的**。某一期扣款失敗時,定期定額會變成 `error`,**之後所有期數都不會再扣款**,也不會自動恢復。
建議開啟重扣:CRM「總設定」→「開發者」→「定期交易重試機制(Beta)」→「定期購買」。開啟後:
* 失敗後最多重扣 **2 次**,每次間隔約 24 小時。
* 發卡銀行明確拒絕(例如卡片停用)或付款結果不明時,不會重扣。
* 重扣中的狀態是 `retryScheduled`;重扣成功就回到 `ongoing`,用完仍失敗就變成 `error`。
* 每一次重扣都會送付款通知,`isRetry` 為 `true`。
定期定額停止後,請聯絡消費者重新訂閱,例如換一張卡走一次[建立定期定額](/api/checkout-subscription/)。
## 取消
[Section titled “取消”](#取消)
呼叫 [`PUT /subscriptions/{subscriptionHid}`](/api/cancel-subscription/),或在 CRM「定期購買」按「終止定期購買」。
* 只接受 `S` 開頭的定期定額編號。
* **請一定要送 body**(至少 `merchantId`),沒送會回 500。
* 可以取消的狀態:`scheduled`、`ongoing`、`retryScheduled`。
* 取消後不能恢復。
消費者提出取消,就應該立即取消,不需要等消費者再次確認;取消之後才扣到的款項,請退款給消費者。
## 查詢
[Section titled “查詢”](#查詢)
* 單筆:[`GET /subscriptions/{id}`](/api/get-subscription/),可以用 `S` 編號或內部 id。
* 某一期的交易:付款通知裡的 `id`,或用[訂單編號查詢](/api/list-order-transactions/)列出同一個 `orderId` 的所有期數。
* 狀態說明見 [Subscription 物件](/api/objects/subscription/)。
GET /subscriptions 不是查這裡的定期定額
[`GET /subscriptions`](/api/list-subscriptions/)(不帶 id)列出的是應援商店的「定期購」訂單,不是用 Payment API 建立的定期定額。
## 3D 驗證與綁卡
[Section titled “3D 驗證與綁卡”](#3d-驗證與綁卡)
* `use3d: true` 時,消費者在結帳頁走 3D 驗證。應援可能依收單設定強制 3D。
* 部分收單機構不支援 0 元驗證,綁卡時會**先授權新台幣 1 元再立即退回**,結帳頁會事先告知消費者。
* 首期日在未來時,消費者在結帳頁只綁卡,卡片是否可用要到首期扣款時才知道。
## 消費者要換卡
[Section titled “消費者要換卡”](#消費者要換卡)
Payment API 目前沒有公開的換卡流程。常見做法:取消舊的定期定額,請消費者重新走一次結帳建立新的。
## 付款通知
[Section titled “付款通知”](#付款通知)
| 時機 | purpose |
| -------------------- | ---------------------------------------------------- |
| 「建立定期定額」第一期付款 | `charge`(`action: subscription`) |
| 「建立預約定期定額」結帳完成 | `schedule_subscription` |
| 之後每一期(含重扣) | `charge`(`action: subscription`) |
| 「用 token 建立定期定額」的第一期 | 不送,以 API 回應為準 |
| 取消、重扣排定或用完 | `subscription_cancelled`、`subscription_retry`,需請應援開啟 |
欄位見[付款通知內容](/api/objects/webhook/)。
# 付款通知(webhook)
> 付款通知在哪裡設定、什麼時候送、送幾次、怎麼確認真假、同一筆交易收到多則怎麼處理,以及沒收到時如何補救。
付款結果確定時,應援會 `POST` 一則通知到你的伺服器。請把它當成「提醒你去查」,**以回查的結果為準**。
## 設定網址
[Section titled “設定網址”](#設定網址)
通知網址只能在 CRM 設定,API 請求沒有這個欄位:
* 位置:CRM「總設定」→「開發者」→「應援金流設定」→「交易資料回傳網址位置」。
* 必須是 `https://`、使用 443 port,而且可以從網際網路連到。內部網址、私有 IP 會被擋下。
* 測試環境與正式環境分別設定。
* **沒有設定網址的期間,通知會直接略過**,之後補設也不會補送。
## 什麼時候會送
[Section titled “什麼時候會送”](#什麼時候會送)
| 情境 | purpose | status |
| --------------------- | ------------------------------------------------------ | ------------------------------------ |
| 信用卡、Apple Pay 付款成功或失敗 | `charge` | `charged`、`claimed` 或 `failed` |
| 3D 驗證開始後 10 分鐘沒完成 | `charge` | `failed`(`message: 3DS_ABANDONED`) |
| 超商代碼、ATM 取號 | `charge` | `charging`(沒有 `success`) |
| 超商代碼、ATM 繳費完成 | `charge` | `charged` 或 `claimed` |
| 超商代碼、ATM 逾期未繳 | `charge` | `failed`(`message: PAYMENT_EXPIRED`) |
| LINE Pay 等待確認、確認結果 | `charge` | `charging`,之後是最終結果 |
| 定期定額每一期(含重扣) | `charge` | 同上,`action: subscription` |
| 綁卡頁完成或失敗 | `token` | — |
| 預約定期定額結帳完成或失敗 | `schedule_subscription` | — |
| 退款、定期定額取消、重扣排定或用完 | `refund`、`subscription_cancelled`、`subscription_retry` | 需請應援開啟 |
**不會**送通知的情況:
* 消費者沒有付款就離開,交易停在 `initiated`。
* [用 token 扣款](/api/token-transactions/)、[用 token 建立定期定額](/api/token-subscriptions/)的第一期。結果以 API 回應為準。
* 付款前的檢查就失敗(例如金額超出付款方式的範圍)。
每一種通知的欄位見[付款通知內容](/api/objects/webhook/)。
## 送出規則
[Section titled “送出規則”](#送出規則)
| 項目 | 規則 |
| ------ | --------------------------------------- |
| 格式 | `POST`,`Content-Type: application/json` |
| 逾時 | 每次 10 秒 |
| 成功的條件 | 回應 HTTP 2xx。不看回應內容 |
| 轉址 | 不跟隨。回 3xx 會被當成失敗 |
| 次數 | **最多 3 次**,間隔約 2 秒、4 秒 |
| 3 次都失敗 | **不會再送**,也沒有後台重送功能 |
| 順序 | 不保證 |
3 次都失敗就不會再送
短暫的部署或當機,就可能漏掉通知。請一定要有下面的[補救機制](#%E6%B2%92%E6%94%B6%E5%88%B0%E9%80%9A%E7%9F%A5%E6%99%82)。
## 確認通知的真假
[Section titled “確認通知的真假”](#確認通知的真假)
付款通知沒有簽章
付款通知沒有簽章,任何人都可以偽造一則送到你的網址。**不要直接相信通知的內容。**
1. 收到通知,先回 HTTP 200。
2. 用 body 的 `id`(27 字元)呼叫 [`GET /transactions/{id}`](/api/get-transaction/)。
3. 確認查到的交易屬於你的訂單:比對 `orderId` 與 `amount`。
4. 依查到的 `status` 更新訂單。
* 剛付款完成的極短時間內,查到的可能還是舊狀態。查到 `charging` 或 `initiated` 時,隔幾秒再查一次。
* `token` 通知沒有 API 可以回查。請確認 `id` 是你剛建立的[綁卡頁](/api/checkout-token/),並核對 `customId`。
* 程式範例見[快速開始](/developers/quickstart/#4-%E6%8E%A5%E6%94%B6%E4%BB%98%E6%AC%BE%E9%80%9A%E7%9F%A5%E4%B8%A6%E5%9B%9E%E6%9F%A5)。
## 同一筆交易的多則通知
[Section titled “同一筆交易的多則通知”](#同一筆交易的多則通知)
* 超商代碼、ATM、LINE Pay 會先收到 `charging`,之後才收到結果,兩則的 `id` 相同。
* 通知可能重複送達,也可能順序顛倒。
* 通知沒有事件編號,所以不能用「收過這個 id」判斷重複。請以**回查到的狀態**決定要做什麼,並讓「標記為已付款」這類動作重複執行也安全。
## 回應要快
[Section titled “回應要快”](#回應要快)
* 應援每次只等 10 秒。請先回 200,再把回查、出貨、寄信等工作放到背景處理。
* 不要回 3xx,也不要讓網址需要登入。
* 防火牆有擋外部連線時,請向應援索取付款通知的來源 IP。
## 沒收到通知時
[Section titled “沒收到通知時”](#沒收到通知時)
請在你的系統排一個定期工作,處理「超過一段時間還沒有結果」的訂單:
1. 找出建立超過 15 分鐘、還沒有最終結果的訂單。
2. 用 [`GET /order/{orderId}/transactions`](/api/list-order-transactions/) 查這個訂單的所有交易。
3. 有 `charged` 或 `claimed`:補標為已付款。
4. 有 `charging`:繼續等待(超商代碼最多 48 小時、ATM 3 天)。
5. 全部是 `initiated` 或 `failed`:視為未付款。
每天也可以用[查詢交易列表](/api/list-transactions/)做一次完整對帳,見[查詢與對帳](/developers/querying/)。
## 測試
[Section titled “測試”](#測試)
* 測試環境**會實際送出**付款通知,送到測試環境 CRM 設定的網址。
* 沒有測試事件 API。要測試接收端,可以建立一筆測試交易並付款,或用 cURL 自己送一則模擬的 body 到你的網址(因為沒有簽章,模擬的 body 和真的格式相同)。
# 商家指南總覽
> 給商家老闆、營運與客服的操作指南。不用寫程式,照著後台步驟就能完成。
這一區寫給**不寫程式的人**:商家老闆、營運、客服與財務。內容都是在 CRM 後台點哪裡、會看到什麼、要注意什麼。需要工程師處理的部分,會明確寫出「請轉給工程師」並附上該看的頁面。
## 依你的階段
[Section titled “依你的階段”](#依你的階段)
準備開始收款
* [開通金流與付款方式](/merchant/activate/)
* [消費者看到的付款流程](/merchant/payment-experience/)
* [交給工程師串接前的準備](/merchant/api-access/)
每天的營運
* [查詢交易與對帳](/merchant/transactions/)
* [退款](/merchant/refunds/)
* [定期定額](/merchant/subscriptions/)
* [電子發票](/merchant/invoices/)
## 不想串接 API?
[Section titled “不想串接 API?”](#不想串接-api)
[WooCommerce 外掛](/merchant/woocommerce/)網站是 WordPress + WooCommerce 的話,裝外掛就能收款。
## 你的工程師需要什麼
[Section titled “你的工程師需要什麼”](#你的工程師需要什麼)
把這兩頁轉給工程師就好:
* [交給工程師串接前的準備](/merchant/api-access/):你要先在後台幫他準備好的東西。
* [開發者快速開始](/developers/quickstart/):工程師從這裡開始串接。
## 看不懂某個詞?
[Section titled “看不懂某個詞?”](#看不懂某個詞)
[名詞對照](/start/glossary/)用白話解釋文件裡常出現的詞,例如「結帳頁」「付款通知」「token」「3D 驗證」。
# 開通金流與付款方式
> 在 CRM 申請金流服務、看懂審核狀態,以及信用卡、Apple Pay、超商代碼、ATM、LINE Pay 各要怎麼開通。
收款前要先開通金流服務。申請在 CRM 後台完成,送出後由應援與銀行審核,通過後會收到 email 通知。
## 登入 CRM
[Section titled “登入 CRM”](#登入-crm)
CRM 後台網址是 `https://{你的網域名稱}.oen.tw/crm`。網域名稱就是你申請時設定的應援頁子網域,例如應援頁是 `ming.oen.tw`,網域名稱就是 `ming`。
忘記網域名稱的話,可以在 CRM「總設定」第一個分頁最下方的「Oen 服務資訊 → 網域名稱」找到。之後工程師串接、WooCommerce 外掛都會用到這個名稱。
## 申請流程
[Section titled “申請流程”](#申請流程)
1. **開始申請。** 還沒開通時,CRM 左側選單上方會顯示「開通金流服務」按鈕。也可以從「金流開通狀態」頁按「申請金流服務」。
2. **填寫申請表「金流服務申請」。** 依序填寫:
* 基本資料:服務項目、公司或組織名稱、聯絡方式、統一編號等。
* 負責人資訊與帳務聯絡人。
* 付款方式及發票。
* 收款帳戶(戶名、銀行帳號、存摺封面影本)。
* 附件:依組織類型需要的登記文件等。檔案格式 PDF、JPG、JPEG、PNG,單檔 2 MB 以內;多頁文件請合併成一個 PDF。
* 勾選「應援金流服務使用條款」。
填到一半可以按「儲存草稿」,之後回來按「繼續完成申請」。
3. **送出申請。** 看到「成功送出金流服務申請!」就完成了。
4. **等待審核。** 進度可以在「金流開通狀態」頁查看,通過後會收到 email 通知。
「帳單顯示名稱」設定後無法更改
這個名稱會出現在消費者的信用卡帳單或交易明細上。請填消費者一看就認得的名稱,只能用中文、英文字母、空格、底線與連字號。
## 審核狀態
[Section titled “審核狀態”](#審核狀態)
申請紀錄上的狀態:
| 畫面上的狀態 | 意思 | 你要做什麼 |
| ------- | --------- | ---------- |
| 草稿 | 還沒送出 | 完成申請表後送出 |
| 待應援收件 | 已送出,等應援確認 | 等待 |
| 應援已收件 | 應援確認資料中 | 等待 |
| 文件未通過 | 資料或文件需要補正 | 依通知補件後重新送出 |
| 已送銀行審核 | 銀行審核中 | 等待 |
| 銀行審核通過 | 銀行已核准 | 等應援完成設定 |
| 銀行審核不通過 | 銀行未核准 | 聯絡應援客服 |
| 已開通金流 | 可以開始收款 | 設定你的服務與價格 |
審核需要的時間依個案而定,有問題請[聯絡客服](#%E9%9C%80%E8%A6%81%E5%8D%94%E5%8A%A9)。
## 各付款方式怎麼開通
[Section titled “各付款方式怎麼開通”](#各付款方式怎麼開通)
| 付款方式 | 怎麼開通 |
| --------- | ----------------------------------------------------------------- |
| 信用卡 | 申請表的必選項目,隨金流一起開通。支援 VISA、Mastercard、JCB;American Express 與銀聯卡另外開通 |
| Apple Pay | 在申請表勾選 |
| 超商代碼 | 申請表沒有這個選項,請聯絡應援開通 |
| ATM 虛擬帳號 | 申請表沒有這個選項,請聯絡應援開通 |
| LINE Pay | 你要先有自己的 LINE Pay 商店帳號,再請應援開通。開通後依下方步驟設定 |
開通後,可以在「總設定」→「金流設定」分頁的「付款方式及費率」查看目前開通的付款方式與你的費率。顯示「未開通請聯絡應援」的,就是還沒開通。
### LINE Pay 設定
[Section titled “LINE Pay 設定”](#line-pay-設定)
應援為你開通 LINE Pay 後,「總設定」→「開發者」分頁會出現「LINE Pay 設定」:
1. 把畫面上顯示的「Oen 正式環境 IP」設定到你自己的 LINE Pay 商家後台。
2. 輸入 LINE Pay 的 Channel ID 與 Channel Secret Key,按「驗證」。
3. 按「開啟 LINE Pay 收款」。看到「LINE Pay 收款已開啟」就完成了。
LINE Pay 的款項由 LINE Pay 直接撥給你,不會出現在應援的撥款裡。
### 第三方服務
[Section titled “第三方服務”](#第三方服務)
「金流設定」分頁的「第三方服務」可以另外申請「應碰收 TWQR」與「藍新金流」。藍新金流主要提供 ATM 轉帳,款項由藍新撥款,也需要藍新審核。申請按鈕在主申請送出後才會出現。
## 開通之後
[Section titled “開通之後”](#開通之後)
* **要用程式串接收款**:請應援開啟 API 串接(開發者模式),再看[交給工程師串接前的準備](/merchant/api-access/)。
* **用 WooCommerce**:看 [WooCommerce 外掛](/merchant/woocommerce/)。
* **設定撥款頻率**:看[查詢交易與對帳](/merchant/transactions/)。
## 需要協助
[Section titled “需要協助”](#需要協助)
* 常見問題:[www.oen.tw/faq](https://www.oen.tw/faq)
* 聯絡客服:CRM 右上角選單的「聯絡客服」(LINE 官方帳號 [lin.ee/zgKzwaZ](https://lin.ee/zgKzwaZ))
* 撥款與帳務:
# 交給工程師串接前的準備
> 請應援開啟 API 串接、在 CRM 產生 token、設定付款通知網址、申請固定 IP,把工程師需要的東西一次準備好。
工程師串接 Payment API 之前,有幾件事要由商家在後台或透過應援完成。照這一頁準備好,再把清單交給工程師。
## 準備清單
[Section titled “準備清單”](#準備清單)
| 項目 | 誰做 | 在哪裡 |
| ------------------- | --------------- | -------------------------------- |
| 1. 金流已開通 | 商家申請、應援審核 | [開通金流與付款方式](/merchant/activate/) |
| 2. 開啟 API 串接(開發者模式) | 請應援開啟 | 聯絡業務或客服 |
| 3. 指派工程師的後台權限 | 商家 | CRM「團隊權限」 |
| 4. 產生 token | 商家或工程師 | CRM「總設定」→「開發者」 |
| 5. 設定付款通知網址 | 工程師提供網址 | CRM「總設定」→「開發者」 |
| 6. 申請固定 IP | 工程師提供 IP,商家聯絡應援 | 聯絡客服或業務 |
| 7. 測試環境帳號 | 請應援協助 | 聯絡業務或客服 |
## 1. 開啟 API 串接
[Section titled “1. 開啟 API 串接”](#1-開啟-api-串接)
API 串接(開發者模式)由應援開啟,商家後台沒有開關。新申請的商家通常已經開啟。
開啟後,CRM 會出現:
* 左側選單「金流管理」→「金流API」:連到 API 文件。
* 左側選單「金流管理」→「定期購買」:管理用 API 建立的定期扣款。
* 「總設定」→「開發者」分頁的「應援金流設定」:產生 token、設定通知網址。
## 2. 指派權限
[Section titled “2. 指派權限”](#2-指派權限)
操作 token 與通知網址的人,要是後台管理員,或在「團隊權限」被指派「金流串接管理員」角色。權限不足時,「產生 Token」按鈕會是灰色的。
## 3. 產生 token
[Section titled “3. 產生 token”](#3-產生-token)
1. 進入 CRM「總設定」→「開發者」分頁,找到「應援金流設定」→「存取 Token」。
2. 按「產生 Token」。
3. 按「複製」,交給工程師。
重新產生 token 會讓舊的立刻失效
* 按下「重新產生 Token」**沒有確認視窗**,舊 token 會立刻失效,正在串接中的系統會馬上無法呼叫 API。確定要換之前,先跟工程師約好時間。
* 離開頁面後就看不到完整的 token 了。忘記複製的話,只能重新產生。
* token 就像密碼,只交給需要的工程師,不要貼在 email、聊天群組或 AI 工具裡。
測試環境和正式環境的 token 是分開的,不能共用。測試環境的 token 要在測試環境的後台產生,測試環境帳號請聯絡應援協助。
## 4. 設定付款通知網址
[Section titled “4. 設定付款通知網址”](#4-設定付款通知網址)
付款完成時,應援會把結果送到你的系統。網址由工程師提供:
1. 在同一個「應援金流設定」區塊,找到「交易資料回傳網址位置」。
2. 貼上工程師提供的網址,必須是 `https://` 開頭。
3. 按「確定送出」,看到「設定成功」就完成了。
退款、取消定期購買與續扣重試的通知需要應援另外開啟,有需要請告訴業務人員。
## 5. 申請固定 IP
[Section titled “5. 申請固定 IP”](#5-申請固定-ip)
正式環境一定要先綁定固定 IP
正式環境的 API 只接受已綁定的 IP。還沒綁定時,工程師呼叫正式環境會被擋下,測試環境則不受影響。
1. 請工程師提供正式環境伺服器呼叫 API 時使用的**固定對外 IP**。
2. 聯絡應援客服或業務,提供你的網域名稱與這些 IP。
3. 應援完成設定後會通知你,工程師就可以開始呼叫正式環境。
透過合作平台(例如已和應援串接好的電商平台)收款的商家,不需要申請。
## 6. 把這些交給工程師
[Section titled “6. 把這些交給工程師”](#6-把這些交給工程師)
* 網域名稱(例如 `ming`)
* 測試環境與正式環境的 token
* 通知網址已設定好的確認
* 固定 IP 開通的確認
* 本站的[開發者快速開始](/developers/quickstart/)
## 常見問題
[Section titled “常見問題”](#常見問題)
**在後台找不到「金流API」選單或「開發者」分頁?** API 串接還沒開啟,或你的帳號沒有權限。請應援開啟,或請管理員指派「金流串接管理員」角色。
**出現「產生存取 Token 失敗,請洽應援客服!」?** 請[聯絡客服](https://lin.ee/zgKzwaZ)並告知網域名稱。
**工程師說呼叫 API 一直回「未授權」?** 常見原因有三個:token 被重新產生過、測試與正式環境的 token 用錯了、正式環境還沒綁定固定 IP。
# 電子發票
> API 交易的電子發票怎麼自動開立、在哪裡查看、怎麼作廢,以及退款時的折讓。
## API 交易會自動開發票
[Section titled “API 交易會自動開發票”](#api-交易會自動開發票)
開通「應援代開電子發票」後,用 API 收款的交易在**付款成功時自動開立發票**,不用另外操作。
* 開立發票需要交易帶有商品明細(品名、數量、單價)。工程師串接時一定會帶,詳見[單次付款](/developers/one-time/)。
* 消費者沒有提供發票資訊時,預設開立雲端發票。
* 由應援代開時,品名前面會加上你的商家名稱,後面加上「(受託代銷)」。
### 怎麼開通
[Section titled “怎麼開通”](#怎麼開通)
* 申請金流時勾選「使用應援代開電子發票(需付費)」,或事後聯絡應援開通。
* 還沒開通時,「金流管理」→「電子發票」頁會顯示「如需開立電子發票,請聯絡應援科技」。
* 想用自己公司的名義開發票,請聯絡應援人員。
## 查看發票
[Section titled “查看發票”](#查看發票)
「金流管理」→「電子發票」有兩個分頁:
* **電子發票明細**:類型有公司戶發票、捐贈發票、雲端發票、二聯式發票。狀態有開立中、已開立、開立失敗、作廢中、已作廢、作廢失敗。
* **折讓明細**:狀態有折讓中、已折讓、折讓失敗、作廢中、已作廢、作廢失敗。
## 作廢與折讓
[Section titled “作廢與折讓”](#作廢與折讓)
後台沒有作廢按鈕
商家後台只能查看發票。發票開錯要作廢,或需要手動折讓時,請[聯絡客服](https://lin.ee/zgKzwaZ)。
退款產生的折讓是自動的,規則見[退款](/merchant/refunds/)。
# 消費者看到的付款流程
> 消費者從你的網站按下付款後,會在應援結帳頁看到什麼、各付款方式怎麼付、多久要付完,以及付款後回到哪裡。
用 Payment API 收款時,消費者會被帶到應援的結帳頁付款。這一頁說明消費者的體驗,方便你設計網站流程、回答消費者問題。
## 單次付款
[Section titled “單次付款”](#單次付款)
1. 消費者在你的網站按下「付款」。
2. 畫面跳到應援結帳頁,網址是 `https://{你的網域名稱}.oen.tw/checkout/…`。
3. 消費者選擇付款方式並完成付款。
4. 付款成功後回到你的網站;失敗則回到你指定的失敗頁。
結帳頁 5 分鐘內要付款
結帳頁從你的網站建立起算 5 分鐘內有效。消費者停太久,會看到「付款時限已過」並被導回失敗頁;過期後才打開,會看到「此付款連結資訊已過期」。所以結帳連結**不能**事先建立好再用 email 或訊息寄給消費者。
## 各付款方式怎麼付
[Section titled “各付款方式怎麼付”](#各付款方式怎麼付)
| 付款方式 | 消費者的流程 | 多久要付完 |
| --------- | --------------------------------------- | ------ |
| 信用卡 | 輸入卡號、到期日、安全碼;可能跳到銀行頁面做 3D 驗證 | 當場完成 |
| Apple Pay | 在支援的裝置(例如 iPhone 的 Safari)會自動出現按鈕,驗證後完成 | 當場完成 |
| LINE Pay | 跳到 LINE Pay 付款 | 20 分鐘內 |
| 超商代碼 | 取得繳費代碼,到超商繳費 | 48 小時內 |
| ATM 虛擬帳號 | 取得虛擬帳號,用 ATM 或網銀轉帳 | 3 天內 |
* **信用卡一定會出現**。要加開超商代碼、LINE Pay、ATM,需要先開通,並請工程師設定。
* 超商代碼、ATM 取號後,消費者會停在應援的繳費資訊頁,按「返回網站」才會回到你的網站。建議你的訂單頁也顯示繳費代碼與期限。
* 各付款方式有金額範圍,例如超商代碼是 80 到 20,000 元,超出範圍的方式不會顯示。詳見[付款方式](/developers/payment-methods/)。
## 付款後回到哪裡
[Section titled “付款後回到哪裡”](#付款後回到哪裡)
* **成功**:回到你的網站指定的成功頁。
* **失敗或逾時**:回到你的網站指定的失敗頁。
* 消費者可能付完款就關掉視窗,沒有回到你的網站。訂單是否付款,請以 CRM 的交易狀態或工程師系統收到的付款通知為準。
## 定期定額
[Section titled “定期定額”](#定期定額)
* 消費者在結帳頁輸入信用卡並同意定期扣款。
* 之後每期由應援自動扣款,**不需要消費者再操作**。
* 每次扣款前 3 天,應援會寄提醒信給消費者(有 Email 時)。
* 消費者要取消,請他聯絡你,由你在 CRM 或透過 API 取消。見[定期定額](/merchant/subscriptions/)。
## 綁卡
[Section titled “綁卡”](#綁卡)
部分服務會請消費者先綁卡(例如儲值、隨用隨扣):
* 消費者在綁卡頁輸入卡片並完成 3D 驗證,頁面 10 分鐘內有效。
* 部分銀行綁卡時會先刷 1 元再立即退回,頁面會事先告知。
## 消費者的信用卡帳單
[Section titled “消費者的信用卡帳單”](#消費者的信用卡帳單)
消費者帳單上顯示的商家名稱,是你申請金流時填的「帳單顯示名稱」。消費者說「帳單上有看不懂的扣款」時,請先確認這個名稱。
## 消費者問「我付款成功了嗎?」
[Section titled “消費者問「我付款成功了嗎?」”](#消費者問我付款成功了嗎)
到 CRM「金流管理」→「金流列表」,用消費者的姓名、Email、電話或你的訂單編號搜尋,看交易狀態。狀態說明見[查詢交易與對帳](/merchant/transactions/)。
# 退款
> 在 CRM 為 API 交易退款:全額或部分退款、超商與 ATM 要填的退款帳戶、已撥款交易怎麼扣回,以及發票折讓。
## 在 CRM 退款
[Section titled “在 CRM 退款”](#在-crm-退款)
1. 到「金流管理」→「金流列表」,點進要退款的交易。
2. 按明細右上角的「退款」。
3. 在「退款申請」視窗填寫退款內容:
* **只有一個品項,或交易沒有商品明細**:直接輸入退款金額,不能高於畫面提示的上限。
* **有多個品項**:先選「全額退款」或「部分退款」,部分退款要逐項選擇品項、數量與金額。
* **超商代碼或 ATM 的交易**:多一步「填寫退款帳號資訊」,填入消費者的銀行名稱與代碼、分行、帳號與戶名。
4. 按「確認送出」,再確認一次「確定退款?送出後無法取消操作」。
5. 看到「已送出退款申請!」就完成了。
退款作業時間依付款方式不同,需要 3 到 7 個工作天。退款完成後,系統會寄信通知你的帳務聯絡人。
## 看不到「退款」按鈕?
[Section titled “看不到「退款」按鈕?”](#看不到退款按鈕)
「退款」按鈕只在這些條件都成立時出現:
* 交易狀態是「付款成功」或「已入帳」。
* 這筆交易還沒退過款。
* 你的帳號有「API 交易退款」權限。
每筆交易在 CRM 只能退一次
第一次退款後,交易狀態就會變成「退款中」或「已退款」,退款按鈕也會消失。要部分退款時,請一次決定好金額。
## 各付款方式的差異
[Section titled “各付款方式的差異”](#各付款方式的差異)
| 付款方式 | 要填退款帳戶嗎 | 退款後的狀態 |
| ------------------------------- | ------------- | ----------------- |
| 信用卡、Apple Pay、LINE Pay、應碰收 TWQR | 不用 | 立刻變成「全額退款」或「部分退款」 |
| 超商代碼、ATM | 要,退款會匯到消費者的帳戶 | 「退款中」,匯款完成後更新 |
* 超商代碼的交易如果**消費者還沒繳費**,工程師用 API 退款時會直接取消繳費代碼。
* LINE Pay 已經退過一次的交易,不能再退(畫面會顯示「LINE Pay 已退過款,無法退款。」)。
## 已撥款的交易
[Section titled “已撥款的交易”](#已撥款的交易)
款項已經撥給你之後才退款,最後一步會顯示:「此金流已撥款至您的帳戶,將於下次撥款時扣除此筆退款金額及退款手續費。」交易狀態會變成「撥款後退款」,扣回的金額可以在「撥款列表」的「退款扣除」看到。
## 發票折讓
[Section titled “發票折讓”](#發票折讓)
交易有開電子發票時:
* 退款**一定要有商品明細**,品項金額合計要等於退款金額。交易本身缺少商品明細時,畫面會顯示「此筆金流缺少開立折讓所需的商品明細,目前無法於 CRM 進行退款。」請聯絡應援客服處理。
* 信用卡等即時退款成功後,系統會**自動開立折讓**。
* 發票還沒開出就全額退款時,系統會直接取消待開的發票,不開折讓。
* 折讓紀錄在「金流管理」→「電子發票」→「折讓明細」。
## 用 API 退款
[Section titled “用 API 退款”](#用-api-退款)
工程師也可以用 API 退款,規則與 CRM 相同,見[開發者的退款說明](/developers/refunds/)。
# 定期定額
> 在 CRM 查看與終止用 API 建立的定期扣款(定期購買),看懂扣款失敗與自動重扣,以及 Subscription API 的訂閱管理。
CRM 裡有幾種「定期」功能,用 Payment API 建立的定期扣款在「**定期購買**」:
| 選單 | 管理什麼 |
| ------------------ | ------------------------------------------------------- |
| 金流管理 →「定期購買」 | 用 Payment API 建立的定期定額(本頁主要說明) |
| 訂閱管理 →「訂閱產品」「訂閱客戶」 | 用 [Subscription API](/products/subscription-api/) 建立的訂閱 |
| 捐贈管理 →「定期定額」 | 應援捐款頁的定期捐款 |
| 回饋品管理 →「訂閱制訂單」 | 回饋品的訂閱 |
「定期購買」選單要在 API 串接開啟後才會出現。
## 查看定期購買
[Section titled “查看定期購買”](#查看定期購買)
列表可以依訂閱起始日期與狀態篩選,上方顯示定期購買人數與總金額。點進去是「定期購買明細」:付款人、起始日期、下次收取日期、每期金額、週期、已扣期數、狀態,以及每一期的扣款紀錄(金流編號、付款時間、金額、手續費、狀態)。
| 狀態 | 意思 |
| -------- | -------------------------------------- |
| 進行中 | 正常扣款中 |
| 異常 - 待重扣 | 這一期扣款失敗,已排定重新扣款。明細會顯示「預計於某時間進行第 N 次重扣」 |
| 異常 | 扣款失敗,沒有再排定重扣 |
| 已取消 | 已終止 |
| 已結束 | 期數已扣完 |
## 終止定期購買
[Section titled “終止定期購買”](#終止定期購買)
1. 在定期購買明細,狀態是「進行中」時,按狀態旁邊的「終止定期購買」。
2. 可以填寫終止原因。
3. 確認後就不會再扣款。
終止後無法回復
終止後這位消費者不會再被扣款,也不能恢復。消費者要重新訂閱,需要再走一次結帳流程。
消費者提出取消,就應該終止,不需要等消費者再確認。終止之後才扣到的款項,請退款給消費者。
## 扣款失敗與自動重扣
[Section titled “扣款失敗與自動重扣”](#扣款失敗與自動重扣)
* 扣款失敗時,狀態會變成「異常 - 待重扣」或「異常」,每一期的結果都在明細的扣款紀錄裡。
* 是否自動重扣,可以在「總設定」→「開發者」→「定期交易重試機制(Beta)」→「定期購買」設定開啟或關閉。
## 消費者要換信用卡
[Section titled “消費者要換信用卡”](#消費者要換信用卡)
Payment API 的定期購買**沒有給消費者自行換卡的頁面**。消費者要換卡時,常見做法是終止舊的定期購買,再請消費者重新訂閱一次。
換卡或綁卡時,金流列表可能出現一筆 1 元的「綁卡試刷」,這是驗證卡片用的,會立即退回。
## Subscription API 的訂閱
[Section titled “Subscription API 的訂閱”](#subscription-api-的訂閱)
用 Subscription API 建立的訂閱在「訂閱管理」查看:
* 訂閱明細是唯讀的,狀態有試用期中、訂閱中、暫停中、失敗、已取消、已終止。
* **消費者有自助管理頁**,可以用 Email 驗證碼登入後換卡、變更方案、取消。在訂閱明細按「複製管理連結」,把連結傳給消費者即可。
* 「訂閱管理」→「設定」可以管理 API 金鑰、Webhook 與 Email 通知。還沒開通時會顯示「此網域尚未開通 Subscription API 整合功能,如需使用請聯繫 OEN。」
# 查詢交易與對帳
> 在 CRM 找到 API 交易、看懂交易狀態、匯出資料,以及設定撥款頻率與查看撥款明細。
## 找到交易
[Section titled “找到交易”](#找到交易)
所有交易都在 CRM「金流管理」→「金流列表」。
* **預設只顯示最近三個月**(三個月前的月初到今天)。要看更早的交易,請調整日期區間。日期可以依「建立日期」或「付款時間」篩選。
* **用 API 建立的交易,類型顯示為「現金購買」**。只想看 API 交易時,在「條件篩選」的類型選「現金購買」。
* 搜尋框可以用這些資料找 API 交易:金流編號、你的訂單編號、姓名、Email、電話。
* API 交易的金流編號是 `P` 開頭,後面接日期與亂數,例如 `P20260928AbCdEfGh`。
點進任一筆就是「金流明細」,可以看到付款人資料、交易類型(單次或定期)、金額、付款方式、手續費、金流狀態、期望撥款日期、訂單編號與商品明細。
### 條件篩選
[Section titled “條件篩選”](#條件篩選)
可以依類型、付款方式、金額區間、金流狀態、是否由應援撥款、收款銀行、收據狀態等條件篩選。
## 交易狀態
[Section titled “交易狀態”](#交易狀態)
| 畫面上的狀態 | 意思 |
| --------- | ------------------------------- |
| 已建立付款意向 | 已建立結帳頁,消費者還沒付款 |
| 尚未付款 | 付款處理中;超商代碼、ATM 等待消費者繳費時也是這個狀態 |
| 付款成功 | 付款完成 |
| 撥款在途 | 款項正在撥給你 |
| 已入帳 | 已撥款給你,或款項由收單方直接撥給你(例如 LINE Pay) |
| 退款中 | 退款處理中。超商代碼、ATM 的退款會停在這裡,直到匯款完成 |
| 全額退款、部分退款 | 已退款。明細會註明退款金額 |
| 撥款後退款 | 已撥款後才退款,退款金額會在下次撥款扣除 |
| 扣款或授權失敗 | 付款失敗 |
| 已失效 | 超商代碼或 ATM 逾期未繳 |
| 已取消 | 交易已取消,例如超商代碼尚未繳費就被取消 |
## 匯出
[Section titled “匯出”](#匯出)
1. 在金流列表先篩選出要的資料並勾選。
2. 按「匯出金流資料」,選「一般格式 - Excel」或「一般格式 - CSV」,再按「匯出」。
3. 檔案不會立刻下載。到左側選單的「下載管理」取得檔案,完成後 1 小時內可以下載。
筆數很多時,建議先篩選類型,再點「選取全部」下載。匯出需要「下載金流」權限。
## 撥款
[Section titled “撥款”](#撥款)
### 撥款頻率
[Section titled “撥款頻率”](#撥款頻率)
「總設定」→「金流設定」→「撥款方式設定」可以查看與編輯撥款頻率:暫不撥款、每週五、每週二與週五、雙週、每月。修改後從下一次撥款開始生效;如果系統正在撥款,會再下一次才生效。
這個區塊要應援為你啟用撥款設定後才會出現。沒有編輯權限時,請聯絡管理員。
### 撥款列表
[Section titled “撥款列表”](#撥款列表)
「金流管理」→「撥款列表」列出每一次撥款:撥款日期、撥款編號、總金額、手續費、匯款金額、撥款狀態與發票狀態。有退款扣除時,會另外顯示「退款扣除」金額。點進去可以看到撥款細項,並匯出 CSV 或 Excel。
不在撥款裡的款項
LINE Pay、應碰收 TWQR、信用卡 - 藍新等由其他機構處理的款項,由該機構直接撥給你,不會出現在應援的撥款裡。
* 撥款帳戶可以在撥款列表的「撥款帳戶資訊」查看。要變更撥款帳戶,請寄信到 。
* 看到「撥款功能目前已暫停」時,請聯絡 。
## 對帳建議
[Section titled “對帳建議”](#對帳建議)
* 每天或每次撥款後,用「金流列表」匯出當期交易,和你自己系統的訂單比對。以「訂單編號」對應最方便。
* 工程師也可以用 API 自動對帳,見[查詢與對帳](/developers/querying/)。
# WooCommerce 外掛
> WordPress + WooCommerce 商家不用寫程式,安裝外掛、填入商店代碼與 Secret Key,就能收信用卡與超商繳費。
應援的 WooCommerce 外掛會在結帳時把消費者導到應援的結帳頁付款,付款完成後自動更新 WooCommerce 訂單狀態。
* 付款方式:信用卡、超商繳費
* 原始碼與下載:[github.com/OEN-Tech/woocommerce-oen-payment](https://github.com/OEN-Tech/woocommerce-oen-payment)
* 網站需求:PHP 8.1 以上、WordPress 6.1 以上、WooCommerce 8.2 以上
## 開始前
[Section titled “開始前”](#開始前)
1. **金流已開通。** 見[開通金流與付款方式](/merchant/activate/)。
2. **請應援開通「OenPay Embed」。** 外掛使用的金鑰要在這個分頁產生。開通後,CRM「總設定」會出現「OenPay Embed」分頁。
3. **要收超商繳費的話,請應援開通超商代碼。** 申請表上沒有這個選項。
外掛不需要開啟 API 串接(開發者模式)。
## 1. 取得 Secret Key
[Section titled “1. 取得 Secret Key”](#1-取得-secret-key)
1. 進入 CRM「總設定」→「OenPay Embed」分頁,找到「Embed API Key」。
2. 按「產生金鑰」。這是高風險操作,要輸入你的網域名稱才能確認。
3. 畫面會顯示 Publishable Key 與 Secret Key。**複製 Secret Key(`sk_` 開頭)**,先存在安全的地方。
4. 勾選「我已妥善保存 Secret Key」,按「立即清除」。
Secret Key 只會顯示一次
* 關閉後就看不到完整的 Secret Key,畫面 5 分鐘後也會自動清除。忘記複製的話,只能重新產生。
* 外掛要的是這把 Secret Key,**不是**「開發者」分頁的「存取 Token」。
* 正式環境後台產生的金鑰就是正式收款用的。外掛的測試模式要用測試環境後台產生的金鑰,測試環境帳號請聯絡應援。
* 同一分頁的「允許的 returnUrl 網域」是給 Embed 用的,只用 WooCommerce 外掛不需要填。
## 2. 安裝外掛
[Section titled “2. 安裝外掛”](#2-安裝外掛)
1. 從 [GitHub 的 Releases 頁](https://github.com/OEN-Tech/woocommerce-oen-payment/releases)下載最新版本的原始碼壓縮檔並解壓縮。
2. 把資料夾上傳到網站的 `/wp-content/plugins/`。
3. 在 WordPress 後台「外掛」頁面啟用「WooCommerce OEN 金流付款」。
## 3. 設定外掛
[Section titled “3. 設定外掛”](#3-設定外掛)
到「WooCommerce」→「設定」→「OEN」:
| 設定項目 | 填什麼 |
| --------------- | ----------------------------------------- |
| 啟用 OEN 金流付款方式 | 勾選。這是總開關 |
| 訂單編號前綴 | 選填。加在 WooCommerce 訂單編號前面 |
| 顯示訂單商品名稱 | 選填。開啟後會把每個商品明細傳給應援 |
| 在 Email 中顯示付款資訊 | 選填。在訂單通知信加上金流編號、繳費代碼等資訊 |
| OEN 測試環境 | 測試時勾選;正式上線前要取消 |
| 商店代碼 | 你的**網域名稱**。例如應援頁是 `ming.oen.tw`,就填 `ming` |
| Secret Key | 貼上第 1 步的 Secret Key |
| Webhook Secret | **留空** |
按「儲存變更」。外掛會自動向應援登記付款通知網址(`https://你的網站/?wc-api=oen_payment`),並把簽章密鑰自動填進「Webhook Secret」。
不用到 CRM 設定 webhook
外掛說明文件寫「至 OEN CRM 後台設定 Webhook 網址」,這一步**不需要做**。CRM「OenPay Embed」分頁新增的 webhook 收不到外掛需要的付款事件,外掛存檔時會自己完成登記。
如果「Webhook Secret」已經手動填過,外掛就不會自動登記。這時請勾選「Re-register webhook」再存一次。
## 4. 啟用付款方式
[Section titled “4. 啟用付款方式”](#4-啟用付款方式)
到「WooCommerce」→「設定」→「付款方式」,啟用「OEN 信用卡」與(或)「OEN 超商繳費」。
## 5. 測試後上線
[Section titled “5. 測試後上線”](#5-測試後上線)
1. 在測試模式下,用信用卡下一筆訂單,確認付款後 WooCommerce 訂單狀態會自動更新。
2. 換成正式環境後台產生的 Secret Key,取消勾選「OEN 測試環境」。
3. 勾選「Re-register webhook」後儲存,讓外掛重新登記正式環境的付款通知。
4. 用正式環境下一筆小額訂單確認,再從 WooCommerce 退款。
切換環境或網站網址之後,都要勾選「Re-register webhook」再存一次。
## 退款
[Section titled “退款”](#退款)
| 付款方式 | 怎麼退 | WooCommerce 會自動更新嗎 |
| ---- | ------------------------------------------------- | ------------------ |
| 信用卡 | 在 WooCommerce 訂單頁退款 | 會 |
| 超商繳費 | 在 CRM 金流明細退款,要填消費者的退款帳戶,見[退款](/merchant/refunds/) | 不會,請手動更新訂單 |
信用卡訂單請從 WooCommerce 退款。從 CRM 退款時,WooCommerce 訂單不一定會自動變成已退款。
## 發票
[Section titled “發票”](#發票)
開通「應援代開電子發票」後,付款完成時會自動開立電子發票,支援手機條碼、自然人憑證、統編與捐贈。見[電子發票](/merchant/invoices/)。
## 常見問題
[Section titled “常見問題”](#常見問題)
**找不到「OenPay Embed」分頁?** 請應援開通 OenPay Embed,並確認你的帳號有 API 串接設定的權限。
**儲存時沒有自動填入 Webhook Secret?** 確認已勾選「啟用 OEN 金流付款方式」,並填好商店代碼與 Secret Key,再勾選「Re-register webhook」儲存一次。成功時會顯示英文訊息「OEN webhook registered」。
**付款成功了,WooCommerce 訂單卻沒有更新?** 先確認網站可以從外部以 https 連線,以及 Webhook Secret 已經自動填入。仍有問題請[聯絡客服](https://lin.ee/zgKzwaZ),並提供網域名稱與訂單編號。
**WooCommerce 的交易在 CRM 哪裡看?** 在「金流管理」→「金流列表」,類型是「現金購買」。
# Embed 嵌入式付款
> 把信用卡付款表單嵌在你自己的頁面,消費者不用離開你的網站。需要先請應援開通。
Embed 把應援的信用卡表單以 iframe 嵌進你的結帳頁。卡號只在應援的 iframe 裡處理,不會經過你的頁面或伺服器。
開始前請確認
* **要先請應援開通**,商家不能自行開通。
* **沒有測試模式**:正式環境的金鑰打下去就是真的扣款。要測試請請應援另外準備測試環境的商家。
* 只支援**信用卡**、只收**新台幣**,金額是 1 到 200,000 的整數。
* 只支援付款後自動請款,不能存卡後再扣款,所以**做不了定期定額**;也**不開發票**。這些需求請用 [Payment API](/developers/quickstart/)。
## 適合誰
[Section titled “適合誰”](#適合誰)
| 適合 | 不適合 |
| ----------------------------------- | ---------------------------- |
| 有自己的結帳頁,希望消費者留在自己的網站完成信用卡付款,又不想處理卡號 | 需要定期扣款、存卡扣款、電子發票,或信用卡以外的付款方式 |
## 開通與後台設定
[Section titled “開通與後台設定”](#開通與後台設定)
1. **確認線上金流已開通。** Embed 建立在線上金流之上,還沒開通的話請先完成[開通金流](/merchant/activate/)。
2. **請應援開通 Embed。** 聯絡負責你的業務人員。開通後,CRM「總設定」會多出一個「OenPay Embed」分頁(網址 `/crm/setting/general?tab=embed`)。 看不到這個分頁時,確認兩件事:應援已經開通 Embed、你的帳號有 API 串接相關權限(例如「金流串接管理員」角色)。
3. **產生金鑰(Embed API Key)。** 按下產生後會同時拿到:
* `pk_` 開頭的 Publishable Key:放在前端,公開沒關係。
* `sk_` 開頭的 Secret Key:**只會顯示一次**,5 分鐘後自動從畫面清除,請立刻交給工程師保存。之後畫面上只看得到開頭幾碼。
4. **登記允許的網域(OenPay Embed 允許的 returnUrl 網域)。** 把放付款表單的頁面與付款完成後導回頁的主機名稱都加進去。
5. **建立 webhook。** 填入 https 的接收網址。建立後會顯示簽章密鑰(`whsec_` 開頭),同樣只顯示一次。也可以由工程師呼叫 `POST /v1/webhooks` 建立。
允許網域是最常見的卡關點
* 清單是空的時候,**完全無法付款**。
* 只填主機名稱,例如 `shop.example.com`,不要加 `https://` 或路徑。
* 比對方式是完全相同,不支援萬用字元。`example.com` 與 `www.example.com` 要各加一筆。
* 不接受 IP、`localhost`、`.local`、`.internal`;至少要兩層網域;最多 25 筆。
* 移除網域會立即生效,請避開營業高峰。
## 付款流程
[Section titled “付款流程”](#付款流程)
1. **後端**用 `sk_` 呼叫 `POST /v1/payment-intents`,拿到 `clientSecret`。
2. **前端**用 `pk_` 與 `clientSecret` 掛上付款表單。
3. **前端**呼叫 `confirmPayment()`,3D 驗證由 SDK 處理。成功後頁面會導向你的 `returnUrl`。
4. **後端**收到 `payment_intent.succeeded` webhook,驗證簽章後出貨。
## 環境與網址
[Section titled “環境與網址”](#環境與網址)
| | 正式環境 |
| --- | -------------------------------------------------------------- |
| API | `https://embed-api.oen.tw/v1` |
| SDK | `https://static-assets.oen.tw/oenpay-sdk/v1.4.0/oenpay.umd.js` |
* 金鑰沒有測試與正式之分,正式環境的金鑰一律真實扣款。
* 要測試時,請應援準備測試環境的商家與金鑰,並改用該環境的 API 與 SDK 網址。
## 驗證
[Section titled “驗證”](#驗證)
每個請求都帶 `Authorization: Bearer <金鑰>`。
| 金鑰 | 格式 | 用在 |
| --------------- | ------------------- | ---------------------- |
| Publishable Key | `pk_` 加 24 碼 | 前端 SDK。只能打 SDK 用的四支端點 |
| Secret Key | `sk_` 加 32 碼 | 後端。用 `pk_` 打後端端點會回 401 |
| Webhook Secret | `whsec_` 開頭,共 54 字元 | 驗證 webhook 簽章 |
* **重新產生金鑰**:舊金鑰會再保留 24 小時,讓你有時間部署新金鑰。
* **金鑰外洩**:先在後台**撤銷金鑰**讓它立即失效,再產生新的一組。只按重新產生的話,外洩的舊金鑰還能再用 24 小時。撤銷到新金鑰上線之間,付款會中斷。
* 以下任一項不成立,都會回 401 `AUTH_INVALID_KEY`,只有 message 不同:金鑰有效、商家啟用中、線上金流已開通、Embed 已開通。
## 後端:建立 PaymentIntent
[Section titled “後端:建立 PaymentIntent”](#後端建立-paymentintent)
金額請由後端依自己的訂單計算,不要相信前端傳來的數字。
| 欄位 | 必填 | 說明 |
| ------------- | -- | -------------------------------------------------------- |
| `amount` | 必填 | 整數,1 到 200,000 |
| `orderId` | 必填 | 1 到 255 字元,不可含 `#` 與控制字元 |
| `returnUrl` | 必填 | 付款成功後導回的網址。正式環境必須是 https,主機名稱要在允許網域清單內 |
| `currency` | 選填 | 只能是 `TWD` |
| `description` | 選填 | 最多 1,000 字元 |
| `metadata` | 選填 | 最多 20 組;key 最多 40 字元,只能用英數與底線;value 最多 500 字元;總共不超過 8 KB |
| `require3ds` | 選填 | `true` 時一律走 3D 驗證 |
```bash
curl -X POST "https://embed-api.oen.tw/v1/payment-intents" \
-H "Authorization: Bearer $OEN_EMBED_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-0001" \
-d '{
"amount": 1200,
"orderId": "order-0001",
"returnUrl": "https://shop.example.com/payment/complete"
}'
```
回應格式是 `{ "data": { ... }, "requestId": "..." }`。`data` 就是 PaymentIntent:
* `clientSecret`:**只在建立時回傳這一次**,交給前端使用。
* `id`:存進你的訂單,之後查詢、退款都用它。
### PaymentIntent 的狀態
[Section titled “PaymentIntent 的狀態”](#paymentintent-的狀態)
| 狀態 | 意思 |
| --------------------------------- | ------------------------------------------------------------- |
| `created`、`requiresPaymentMethod` | 等待付款者輸入卡片 |
| `requiresAction` | 等待 3D 驗證。取消或逾時會回到 `requiresPaymentMethod`,可以用同一筆重新付款 |
| `processing` | 付款處理中 |
| `succeeded` | 付款成功(終態) |
| `failed` | 付款失敗(終態)。原因在 `lastPaymentError.code`;要重新付款請建立新的 PaymentIntent |
| `canceled` | 已取消(終態) |
停在 `processing` 超過 15 分鐘的付款,應援每 5 分鐘會自動對帳一次,所以大約 20 分鐘後再查通常就有結果。
## 前端:載入 SDK 並付款
[Section titled “前端:載入 SDK 並付款”](#前端載入-sdk-並付款)
SDK 還沒有發佈到 npm,請用 CDN 載入。正式環境建議鎖定版本並加上 SRI,`integrity` 的值取自同目錄的 `sri-hashes.json`。
```html
```
* 想一直用最新版,可以改載 `https://static-assets.oen.tw/oenpay-sdk/v1/oenpay.umd.js`。這個網址每次發佈都會更新,**不能**加 `integrity`。
* 結帳頁必須是 https,付款 iframe 不允許嵌在 http 頁面裡。
### 導回頁
[Section titled “導回頁”](#導回頁)
只有付款成功才會導向 `returnUrl`,網址會帶上 `payment_intent_client_secret` 與 `redirect_status`。`redirect_status` 只是提示,導回頁一定要再用 `oenpay.retrievePaymentIntent(clientSecret)` 查一次狀態。
出貨以 webhook 為準
前端查到的結果只用來給消費者即時回饋。出貨請以後端收到的 `payment_intent.succeeded` 為準,出貨前再用 `GET /v1/payment-intents/{id}` 交叉確認。
## Webhook
[Section titled “Webhook”](#webhook)
### 事件
[Section titled “事件”](#事件)
| 事件 | 什麼時候 |
| --------------------------------------------------- | ------------------------------------- |
| `payment_intent.created` | 建立 PaymentIntent |
| `payment_intent.requires_action` | 等待 3D 驗證 |
| `payment_intent.succeeded` | 付款成功(出貨依據) |
| `payment_intent.failed` | 付款失敗,原因在 `data.lastPaymentError.code` |
| `payment_intent.canceled` | 已取消 |
| `refund.created`、`refund.succeeded`、`refund.failed` | 退款 |
### 內容與 header
[Section titled “內容與 header”](#內容與-header)
```json
{
"id": "evt_0123456789abcdef01234567",
"type": "payment_intent.succeeded",
"api_version": "2026-06-01",
"created": 1790000000,
"data": { "id": "…", "status": "succeeded", "amount": 1200, "orderId": "order-0001" }
}
```
* `data` 是事件發生時的 PaymentIntent 或 Refund,不含 `clientSecret`。
* Header:`OenPay-Signature`、`OenPay-Event-Type`、`OenPay-Event-Id`。重試時另外帶 `OenPay-Retry-Count`,`OenPay-Event-Id` 不變。
### 驗證簽章
[Section titled “驗證簽章”](#驗證簽章)
`OenPay-Signature` 的格式是 `t=,v1=`:
1. 從 header 拆出 `t` 與所有 `v1=`。
2. 用完整的 `whsec_` 字串當 key,對 `{t}.{原始 body}` 計算 HMAC-SHA256,輸出 hex。
3. 用固定時間比較函式比對,任一個 `v1=` 相符就通過;`t` 與現在相差超過 300 秒就拒絕。
三個常見錯誤
* 先 `JSON.parse` 再轉回字串會改變 body,驗證簽章一定失敗。
* 時間要用 header 的 `t`,不是 body 的 `created`。重試時兩者不同。
* 不要用一般的字串比較。
- cURL
```bash
# 在本機模擬一個帶簽章的通知,測試你的驗簽程式
BODY='{"id":"evt_test_0001","type":"payment_intent.succeeded","created":1790000000,"data":{}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$OEN_EMBED_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "http://localhost:3000/webhooks/oen" \
-H "Content-Type: application/json" \
-H "OenPay-Signature: t=$T,v1=$SIG" \
-d "$BODY"
```
- Node.js
```js
import crypto from "node:crypto";
import express from "express";
const app = express();
const TOLERANCE_SECONDS = 300;
// OenPay-Signature: t=,v1=[,v1=],簽的是 `${t}.${原始 body}`
function verifySignature(rawBody, header, secret) {
const parts = (header ?? "").split(",").map((p) => p.trim());
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
if (!Number.isFinite(t) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
// 更換密鑰後 24 小時內會帶兩個 v1=,任一個相符就通過
return sigs.some((sig) => {
const received = Buffer.from(sig, "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
// 驗簽要用原始 body,不要先 JSON.parse
app.post("/webhooks/oen", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifySignature(rawBody, req.header("OenPay-Signature"), process.env.OEN_EMBED_WEBHOOK_SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
// 用 event.id(evt_ 開頭)判斷是否處理過;處理過就直接回 200
res.sendStatus(200);
// 回應之後再處理出貨、寄信等耗時工作
});
```
- PHP
```php
,v1=[,v1=],簽的是 "{t}.{原始 body}"
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') $t = (int) $value;
if ($key === 'v1') $sigs[] = $value;
}
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
$matched = false;
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) $matched = true;
}
if ($t === null || abs(time() - $t) > 300 || !$matched) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($payload, true);
// 用 $event['id'](evt_ 開頭)判斷是否處理過;處理過就直接回 200
http_response_code(200);
```
- Python
```python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
# OenPay-Signature: t=,v1=[,v1=],簽的是 f"{t}.{原始 body}"
def verify_signature(payload: bytes, header: str, secret: str) -> bool:
parts = [p.strip() for p in header.split(",")]
t = next((p[2:] for p in parts if p.startswith("t=")), "")
sigs = [p[3:] for p in parts if p.startswith("v1=")]
if not t.isdigit() or not sigs:
return False
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
@app.post("/webhooks/oen")
def oen_webhook():
payload = request.get_data()
if not verify_signature(payload, request.headers.get("OenPay-Signature", ""), os.environ["OEN_EMBED_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# 用 event["id"](evt_ 開頭)判斷是否處理過;處理過就直接回 200
return "", 200
```
### 投遞與重試
[Section titled “投遞與重試”](#投遞與重試)
* 單次逾時 30 秒。請先回 2xx,再處理寄信、出貨等耗時工作。
* 沒有回 2xx 會重試,最多 5 次,間隔約 1 分鐘、5 分鐘、30 分鐘、2 小時、24 小時,各有約 ±20% 的隨機誤差。
* 同一個事件可能送達不只一次,請用事件 `id` 判斷是否處理過。
* 投遞紀錄保留 30 天,可用 `GET /v1/webhooks/{id}/logs` 查詢。
* 更換密鑰(`POST /v1/webhooks/{id}/rotate-secret`)後 24 小時內,header 會同時帶新舊兩個 `v1=`。
* 沒有「發送測試事件」的 API。要測試驗證簽章程式,請用上面的 cURL 範例在本機模擬。
## 退款
[Section titled “退款”](#退款)
Embed 的退款由後端呼叫 `POST /v1/refunds`:
| 欄位 | 必填 | 說明 |
| ----------------- | -- | -------------------------------------------------- |
| `paymentIntentId` | 必填 | PaymentIntent 的 `id` |
| `reason` | 必填 | `duplicate`、`fraudulent` 或 `requested_by_customer` |
| `amount` | 選填 | 不帶就退剩餘全額。可以多次部分退款,上限是 `amountRefundable` |
| `metadata` | 選填 | 同 PaymentIntent |
* 付款成功後 180 天內可以退款。
* 請帶 `Idempotency-Key`,例如 `refund-訂單編號-1`。
HTTP 200 不代表退款成功
金流商拒絕時 HTTP 仍是 200,結果在回應 body 的 `status`(`succeeded` 或 `failed`)。收到 409 `REFUND_RECONCILIATION_REQUIRED` 或 500 時,退款可能已經送出:**不要換一把新的 Idempotency-Key 重送**,先用 `GET /v1/refunds?paymentIntentId=…` 查詢結果。退款沒有自動對帳,久未有結果請帶 `requestId` 聯絡應援。
## 冪等性與頻率限制
[Section titled “冪等性與頻率限制”](#冪等性與頻率限制)
* 支援 `Idempotency-Key` 的端點:建立 PaymentIntent、建立退款、capture,以及 SDK 代打的 confirm。key 是 1 到 255 個 ASCII 可見字元。
* 同一把 key 重送會拿到第一次的回應,header 帶 `Idempotency-Replayed: true`。
* 前一次處理超過 5 分鐘沒有結果,會重新處理;記錄保留 24 小時。
* 同一把 key 用在不同內容或端點,會回 422 `IDEMPOTENCY_KEY_REUSED`。
* 頻率限制:
* 每把 Secret Key 每分鐘 500 次。超過回 429 `AUTH_RATE_LIMITED`,請依 `Retry-After` 等待。
* Publishable Key:每個 IP 每分鐘 30 次,每把每分鐘 60 次。
* 另外還有一層防火牆上限:**同一個 IP 每 5 分鐘最多 100 個請求**,每把 Publishable Key 每 5 分鐘最多 600 個請求。超過會直接回 403,body 不是一般的錯誤格式,也沒有 `Retry-After`。批次對帳或大量補查時,要自己把請求分散在時間上。
## 錯誤
[Section titled “錯誤”](#錯誤)
錯誤格式:
```json
{
"error": { "code": "INVALID_AMOUNT", "message": "(錯誤說明)", "field": "amount" },
"requestId": "req_…"
}
```
程式判斷請比對 `code`,`message` 的文字可能調整。回報問題時附上 `requestId`,回應 header 的 `X-Request-Id` 也是同一個值。
| HTTP | 錯誤碼 | 意思與處理 |
| ---- | ------------------------------------------------------------------------------- | ----------------------------------------- |
| 400 | `INVALID_REQUEST`、`INVALID_AMOUNT`、`INVALID_CURRENCY`、`METADATA_LIMIT_EXCEEDED` | 參數不符規則,看 `field` 與 `message` 修正 |
| 400 | `MERCHANT_NOT_CONFIGURED` | 商家的金流設定不完整,請聯絡應援。不是卡片問題 |
| 400 | `PAYMENT_INTENT_ALREADY_SUCCEEDED`、`PAYMENT_INTENT_CANCELED` | 已付款或已取消,不能再做這個動作 |
| 400 | `REFUND_AMOUNT_EXCEEDED`、`REFUND_WINDOW_EXPIRED` | 超過可退金額,或超過付款後 180 天 |
| 401 | `AUTH_INVALID_KEY` | 金鑰缺少、錯誤或已撤銷;或用 `pk_` 打了後端端點;或商家尚未開通 Embed |
| 402 | `CARD_*`、`3DS_*` | 付款失敗,見下表 |
| 403 | `FORBIDDEN_ORIGIN`、`DOMAIN_NOT_REGISTERED` | 結帳頁的主機名稱不在允許網域清單內 |
| 404 | `PAYMENT_INTENT_NOT_FOUND`、`REFUND_NOT_FOUND` 等 | 資源不存在,或不屬於這把金鑰的商家 |
| 409 | `IDEMPOTENCY_KEY_IN_PROGRESS`、`REFUND_IN_PROGRESS` | 同一筆正在處理,稍後用同一把 key 重送 |
| 409 | `REFUND_RECONCILIATION_REQUIRED` | 退款結果不明,不可換 key 重送,先查詢 |
| 422 | `IDEMPOTENCY_KEY_REUSED` | 同一把 key 用在不同內容或端點 |
| 429 | `AUTH_RATE_LIMITED` | 超過頻率限制,依 `Retry-After` 等待 |
| 500 | `INTERNAL_ERROR` | 結果未知。付款或退款可能已經發生,先查狀態,不要直接重來 |
### 付款失敗的原因
[Section titled “付款失敗的原因”](#付款失敗的原因)
這些值會出現在前端 SDK 的錯誤、`payment_intent.failed` webhook 與 `lastPaymentError.code`。請不要把內部訊息原樣轉給付款者,可以參考右欄的文案。
| 錯誤碼 | 原因 | 建議對付款者顯示 |
| ------------------------- | -------------- | ------------------------- |
| `CARD_DECLINED` | 發卡銀行拒絕 | 發卡銀行拒絕這筆交易,請改用其他卡片或聯絡發卡銀行 |
| `CARD_NOT_SUPPORTED` | 商家沒有開通國外卡或這種卡別 | 這張卡目前無法使用,請改用其他卡片 |
| `CARD_INSUFFICIENT_FUNDS` | 額度不足 | 卡片額度不足,請改用其他卡片 |
| `CARD_EXPIRED` | 卡片過期 | 卡片已過期,請改用其他卡片 |
| `CARD_INVALID_NUMBER` | 卡號無效 | 卡號有誤,請重新確認 |
| `CARD_INVALID_CVV` | 安全碼錯誤 | 安全碼有誤,請重新確認 |
| `CARD_RISK_DECLINED` | 未通過風險控管 | 這筆交易無法完成,請改用其他卡片 |
| `3DS_FAILED` | 3D 驗證未通過 | 銀行安全驗證未通過,請重新付款或改用其他卡片 |
| `3DS_CANCELED` | 付款者在驗證頁按了取消 | 您已取消銀行安全驗證,要繼續請重新付款 |
| `3DS_TIMEOUT` | 3D 驗證逾時 | 銀行安全驗證逾時,請重新付款 |
## 端點一覽
[Section titled “端點一覽”](#端點一覽)
後端用 Secret Key 呼叫:
| Method | Path | 用途 |
| ---------------------- | --------------------------------- | ----------------------------------------------------------- |
| `POST` | `/v1/payment-intents` | 建立 PaymentIntent |
| `GET` | `/v1/payment-intents` | 列表,可用 `status`、`createdGte`、`createdLte` 篩選,`limit` 1 到 100 |
| `GET`、`PATCH` | `/v1/payment-intents/{id}` | 查詢;更新(只能改 `metadata`) |
| `POST` | `/v1/payment-intents/{id}/cancel` | 取消 |
| `POST` | `/v1/refunds` | 建立退款 |
| `GET` | `/v1/refunds`、`/v1/refunds/{id}` | 查詢退款 |
| `POST`、`GET` | `/v1/webhooks` | 建立、列出 webhook |
| `GET`、`PATCH`、`DELETE` | `/v1/webhooks/{id}` | 管理 webhook |
| `GET` | `/v1/webhooks/{id}/logs` | 投遞紀錄 |
| `POST` | `/v1/webhooks/{id}/rotate-secret` | 更換簽章密鑰 |
`/v1/tokens`、`/v1/confirm`、`/v1/payment-intents/{id}/status` 與 `/v1/3ds/*` 由 SDK 代為呼叫,後端不需要處理。
## 測試
[Section titled “測試”](#測試)
在測試環境的商家上測試。到期日填未來任一日期,安全碼任意三碼:
| 卡號 | 結果 |
| ------------------ | -------------- |
| `4000000000000002` | 直接成功 |
| `4000010000000010` | 跳出 3D 驗證,驗證後成功 |
* 要確定走到 3D 驗證,建立 PaymentIntent 時帶 `require3ds: true`。
* 測試環境另有 `POST /v1/3ds/simulate`,可以不經過驗證頁,直接把付款推到成功、失敗、取消或逾時,方便測試導回頁與 webhook。正式環境沒有這支 API。
## 上線前檢查
[Section titled “上線前檢查”](#上線前檢查)
* [ ] Secret Key 與 Webhook Secret 只放在後端環境變數或密鑰管理服務,沒有進版本控制。
* [ ] 正式站的主機名稱已加入允許網域清單。
* [ ] Webhook 接收端是 https、會驗證簽章、會用事件 `id` 判斷是否重複。
* [ ] 出貨以 `payment_intent.succeeded` 為準,出貨前再用 API 確認。
* [ ] 建立 PaymentIntent 與退款都帶 `Idempotency-Key`。
* [ ] 退款流程會檢查回應 body 的 `status`,不是只看 HTTP 200。
* [ ] 付款或退款收到 500 時,先查狀態,不直接重來。
* [ ] 在測試環境走過付款(含 3D 驗證成功與取消)、退款與 webhook。
# Subscription API
> 建立產品與方案,管理每一筆訂閱從試用、扣款、變更方案到取消的完整生命週期。需要先開通。
Subscription API 用來經營訂閱制服務:你先建立**產品**與**方案**,把訂閱頁網址放在自己的網站,消費者在應援的訂閱頁輸入卡片完成訂閱。之後的續扣、試用、方案變更、取消與退款,都可以用 API 管理,並透過 webhook 同步狀態。
開始前請確認
* **需要開通**:網域要開啟 API 串接(開發者模式),操作的 CRM 帳號也要有 Subscription API 金鑰的權限。
* **收單限制**:訂閱的首期扣款目前只支援部分收單機構,不符合時訂閱頁會回 422 `SA047`。開通前請先跟業務人員確認。
* **沒有公開的測試環境**:目前只有正式環境可以使用。
## 跟 Payment API 的定期定額差在哪
[Section titled “跟 Payment API 的定期定額差在哪”](#跟-payment-api-的定期定額差在哪)
只要「每月固定金額自動扣款」,用 [Payment API 的定期定額](/developers/subscriptions/)就夠了。需要以下功能時,再考慮 Subscription API:
| | Payment API 定期定額 | Subscription API |
| ------- | ------------------------- | ------------------------ |
| 建立方式 | 每位訂閱人各建立一次結帳頁 | 先建立產品與方案,所有人共用同一個訂閱頁網址 |
| 週期 | 每月,或每 1 到 12 個月 | 天、月、年,任意間隔 |
| 試用期 | 不支援 | 支援試用天數與試用次數限制 |
| 方案變更 | 不支援 | 支援;分級產品升級會產生補差額的付款連結 |
| 取消 | 立即取消 | 期末取消、恢復、立即終止並按日計算退款 |
| 扣款失敗 | 預設不重扣,可在 CRM 開啟重扣(最多 2 次) | 自動重試,另可產生補繳連結 |
| 訂閱人自助管理 | 無 | 有,訂閱人以 email 驗證碼登入 |
| 幣別 | 只支援 TWD | TWD |
| 金鑰 | CRM 產生的 Payment API token | 獨立的 `sub_sk_` 金鑰,可限制權限範圍 |
| Webhook | 沒有簽章 | 有簽章,17 種事件,可查投遞紀錄並手動重送 |
## 取得金鑰
[Section titled “取得金鑰”](#取得金鑰)
1. 確認網域已開啟 API 串接(開發者模式)。新申請的商家預設就是開啟的;舊商家請洽業務人員。沒開時產生金鑰會回 `M002`。
2. 進入 CRM「訂閱管理」→「設定」→「API 金鑰」(`/crm/subscription/settings/api-keys`)。你的帳號需要 Subscription API 金鑰的權限。
3. 建立金鑰並勾選需要的權限範圍(scope)。金鑰格式是 `sub_sk_` 加 32 碼,**只會顯示一次**。
4. 在同一區的「Webhook」頁(`/crm/subscription/settings/webhooks`)設定接收網址,取得簽章密鑰(`sub_whsec_` 開頭)。
| scope | 可以做的事 |
| -------------------- | -------------------------- |
| `read:*` | 所有查詢 |
| `write:product` | 建立、更新產品 |
| `write:plan` | 建立、更新方案 |
| `write:subscription` | 變更方案、變更期間、取消、恢復、終止、退款、補繳連結 |
| `write:customer` | 更新客戶資料 |
| `ops:webhook` | 查詢投遞紀錄、手動重送 |
* 更換金鑰後,舊金鑰會再有效 24 小時。
* 金鑰無效回 401 `X022`,權限範圍不足回 403 `X023`。
## 共通規格
[Section titled “共通規格”](#共通規格)
* Base URL:`https://subscription-api.oen.tw`,所有路徑都以 `/v1` 開頭。
* 每個請求帶 `Authorization: Bearer sub_sk_…`。
* 成功回應一律 HTTP 200,格式是 `{ "data": …, "paging"?: { "next": … } }`。
* 錯誤回應是 `{ "errno": "SA001", "message": "…" }`,HTTP 狀態碼依錯誤而定。程式判斷請比對 `errno`。
* 分頁:把上一頁的 `paging.next` 原封不動放進 `?page=`;沒有下一頁時是 `null`。列表每頁 50 筆。
* ID 前綴:訂閱 `sub_`、產品 `prod_`、方案 `plan_`、客戶 `cus_`、事件 `evt_`。
### 訂閱狀態
[Section titled “訂閱狀態”](#訂閱狀態)
| 狀態 | 意思 |
| ------------ | ------------------------------- |
| `trialing` | 試用中 |
| `active` | 正常扣款中 |
| `paused` | 扣款失敗,暫停中。`nextChargeAt` 是下次重試時間 |
| `failed` | 扣款失敗且不再重試 |
| `cancelled` | 已取消(期末取消) |
| `terminated` | 已終止 |
`nextChargeAt` 是實際扣款的時間,一律是台北時間上午 9 點。
## 端點
[Section titled “端點”](#端點)
| Method | Path | scope | 說明 |
| ------ | -------------------------------------------- | -------------------- | --------------------------------------------------------- |
| `POST` | `/v1/products` | `write:product` | 建立產品。`subscriptionType` 是 `fixedPeriod` 或 `tiered`,建立後不能改 |
| `GET` | `/v1/products` | `read:*` | 產品列表 |
| `GET` | `/v1/products/{productId}` | `read:*` | 查詢產品 |
| `PUT` | `/v1/products/{productId}` | `write:product` | 更新產品 |
| `GET` | `/v1/products/{productId}/subscription-url` | `read:*` | 取得訂閱頁網址 |
| `POST` | `/v1/products/{productId}/plans` | `write:plan` | 建立方案。`billingPeriod` 建立後不能改 |
| `GET` | `/v1/products/{productId}/plans` | `read:*` | 方案列表 |
| `PUT` | `/v1/products/{productId}/plans/{planId}` | `write:plan` | 更新方案 |
| `GET` | `/v1/products/{productId}/subscriptions` | `read:*` | 某產品的訂閱列表 |
| `GET` | `/v1/subscriptions/{id}` | `read:*` | 查詢訂閱,含產品、方案、客戶、付款與最近 100 筆操作紀錄 |
| `POST` | `/v1/subscriptions/{id}/plan-change` | `write:subscription` | 變更方案 |
| `POST` | `/v1/subscriptions/{id}/period-change` | `write:subscription` | 變更期間結束日 |
| `POST` | `/v1/subscriptions/{id}/cancel` | `write:subscription` | 期末取消 |
| `POST` | `/v1/subscriptions/{id}/resume` | `write:subscription` | 恢復已取消的訂閱 |
| `POST` | `/v1/subscriptions/{id}/terminate` | `write:subscription` | 立即終止,回應附退款試算 |
| `POST` | `/v1/subscriptions/{id}/refunds` | `write:subscription` | 終止後退款 |
| `POST` | `/v1/subscriptions/{id}/recovery-links` | `write:subscription` | 產生補繳連結,3 天內有效 |
| `GET` | `/v1/customers` | `read:*` | 客戶列表 |
| `GET` | `/v1/customers/{customerId}` | `read:*` | 查詢客戶 |
| `PUT` | `/v1/customers/{customerId}` | `write:customer` | 更新客戶姓名、電話、地址(email 不能改) |
| `GET` | `/v1/webhook-deliveries` | `ops:webhook` | 投遞紀錄 |
| `GET` | `/v1/webhook-deliveries/{deliveryId}` | `ops:webhook` | 單筆投遞紀錄 |
| `POST` | `/v1/webhook-deliveries/{deliveryId}/resend` | `ops:webhook` | 手動重送 |
### 主要欄位
[Section titled “主要欄位”](#主要欄位)
**建立產品** `POST /v1/products`
| 欄位 | 必填 | 說明 |
| ------------------------------------------------------ | -- | ------------------------------------------------------ |
| `name` | 必填 | 最多 50 字 |
| `subscriptionType` | 必填 | `fixedPeriod` 或 `tiered` |
| `status` | 選填 | 預設 `inactive` |
| `summary` | 選填 | 最多 100 字 |
| `description` | 選填 | 產品說明 |
| `trialReuse` | 選填 | 試用可以用幾次:`unlimited`、`once_per_plan`、`once_per_product` |
| `gracePeriodDays` | 選填 | 寬限天數 |
| `basicInfoFields` | 選填 | 訂閱頁要不要收電話、地址,以及是否接受海外地址 |
| `websiteUrl`、`successRedirectUrl`、`failureRedirectUrl` | 選填 | 你的網站與導回網址,必須是 https |
| `customerServicePhone`、`customerServiceEmail` | 選填 | 顯示在訂閱頁的客服資訊 |
**建立方案** `POST /v1/products/{productId}/plans`
| 欄位 | 必填 | 說明 |
| --------------- | -- | ---------------------------------------------------------- |
| `name` | 必填 | 1 到 30 字 |
| `price` | 必填 | 整數,0 以上 |
| `billingPeriod` | 必填 | `{ "unit": "day" \| "month" \| "year", "interval": 1 以上 }` |
| `trialDays` | 選填 | 試用天數;沒帶時沿用產品的設定 |
| `description` | 選填 | 最多 1,000 字 |
| `status` | 選填 | 預設 `active` |
消費者從訂閱頁回到你的網址時,會帶上 `?subscriptionId=sub_…&result=success|failure&action=subscription_create|recovery_payment|plan_upgrade`。導回只是提示,請以 webhook 或查詢結果為準。
## Idempotency-Key
[Section titled “Idempotency-Key”](#idempotency-key)
變更方案、變更期間、取消、恢復、終止、退款與 webhook 重送都**一定要帶** `Idempotency-Key`,沒帶會回 400 `SA034`。產品、方案、客戶的建立與更新,以及補繳連結,不需要帶。
* Header 名稱請寫成 `Idempotency-Key`(或全小寫)。
* 取消、恢復、終止:同一把 key 重送時,如果訂閱已經在目標狀態,會回原本的結果;內容不同則回 409 `SA044`。
* 退款:
* 處理中回 409 `SA025`。
* 先前已失敗回 409 `SA026`,要再退請換一把新的 key。
* 結果未定回 503 `SA028`,**請用同一把 key 重試**,不要換新的。
## Webhook
[Section titled “Webhook”](#webhook)
### 事件
[Section titled “事件”](#事件)
`subscription_created`、`subscription_renewed`、`subscription_failed`、`subscription_recovered`、`subscription_paused`、`subscription_cancelled`、`subscription_resumed`、`subscription_terminated`、`subscription_plan_changed`、`subscription_period_changed`、`subscription_payment_method_updated`、`subscription_payment_refunded`、`subscription_trial_started`、`subscription_trial_ending`、`subscription_trial_ended`、`subscription_upcoming_charge`、`customer_updated`。
設定 webhook 時沒有指定事件,就會收到全部事件。
### 內容與 header
[Section titled “內容與 header”](#內容與-header)
```json
{
"id": "evt_…",
"type": "subscription_renewed",
"created": 1790000000,
"data": {
"subscription": { "id": "sub_…", "status": "active" },
"paymentDetail": { "amount": 299, "currency": "twd" }
}
}
```
Header 包含 `OenPay-Signature`、`OenPay-Event-Type`、`OenPay-Event-Id` 與 `OenPay-Delivery-Attempt`。
### 驗證簽章
[Section titled “驗證簽章”](#驗證簽章)
格式與算法跟 Embed 相同:`OenPay-Signature: t=,v1=`,用 `sub_whsec_` 密鑰對 `{t}.{原始 body}` 計算 HMAC-SHA256。伺服器端沒有規定時間差,建議拒絕超過 5 分鐘的通知。更換密鑰後 24 小時內會同時帶新舊兩個 `v1=`。
* cURL
```bash
# 在本機模擬一個帶簽章的通知,測試你的驗簽程式
BODY='{"id":"evt_test_0001","type":"payment_intent.succeeded","created":1790000000,"data":{}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$OEN_SUBSCRIPTION_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "http://localhost:3000/webhooks/oen" \
-H "Content-Type: application/json" \
-H "OenPay-Signature: t=$T,v1=$SIG" \
-d "$BODY"
```
* Node.js
```js
import crypto from "node:crypto";
import express from "express";
const app = express();
const TOLERANCE_SECONDS = 300;
// OenPay-Signature: t=,v1=[,v1=],簽的是 `${t}.${原始 body}`
function verifySignature(rawBody, header, secret) {
const parts = (header ?? "").split(",").map((p) => p.trim());
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
if (!Number.isFinite(t) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
// 更換密鑰後 24 小時內會帶兩個 v1=,任一個相符就通過
return sigs.some((sig) => {
const received = Buffer.from(sig, "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
// 驗簽要用原始 body,不要先 JSON.parse
app.post("/webhooks/oen", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifySignature(rawBody, req.header("OenPay-Signature"), process.env.OEN_SUBSCRIPTION_WEBHOOK_SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(rawBody);
// 用 event.id(evt_ 開頭)判斷是否處理過;處理過就直接回 200
res.sendStatus(200);
// 回應之後再處理出貨、寄信等耗時工作
});
```
* PHP
```php
,v1=[,v1=],簽的是 "{t}.{原始 body}"
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') $t = (int) $value;
if ($key === 'v1') $sigs[] = $value;
}
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);
$matched = false;
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) $matched = true;
}
if ($t === null || abs(time() - $t) > 300 || !$matched) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($payload, true);
// 用 $event['id'](evt_ 開頭)判斷是否處理過;處理過就直接回 200
http_response_code(200);
```
* Python
```python
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
# OenPay-Signature: t=,v1=[,v1=],簽的是 f"{t}.{原始 body}"
def verify_signature(payload: bytes, header: str, secret: str) -> bool:
parts = [p.strip() for p in header.split(",")]
t = next((p[2:] for p in parts if p.startswith("t=")), "")
sigs = [p[3:] for p in parts if p.startswith("v1=")]
if not t.isdigit() or not sigs:
return False
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + payload, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
@app.post("/webhooks/oen")
def oen_webhook():
payload = request.get_data()
if not verify_signature(payload, request.headers.get("OenPay-Signature", ""), os.environ["OEN_SUBSCRIPTION_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json()
# 用 event["id"](evt_ 開頭)判斷是否處理過;處理過就直接回 200
return "", 200
```
### 投遞與重試
[Section titled “投遞與重試”](#投遞與重試)
* 單次逾時 15 秒,只有 2xx 算成功,**不跟隨 3xx 轉址**。
* 失敗會自動重試 3 次,間隔約 1 分鐘、5 分鐘、30 分鐘。
* 可用 `GET /v1/webhook-deliveries` 查詢,用 `POST /v1/webhook-deliveries/{id}/resend` 手動重送。
* 同一個事件可能送達不只一次,請用事件 `id` 判斷是否處理過。
## 錯誤碼
[Section titled “錯誤碼”](#錯誤碼)
### 共通
[Section titled “共通”](#共通)
| errno | HTTP | 意思 |
| ------ | ---- | ------------------------------- |
| `X006` | 400 | 欄位驗證失敗,`message` 會列出欄位與原因 |
| `X019` | 400 | `page` 對不到這個列表的資料 |
| `X028` | 400 | `page` 無法解析 |
| `X016` | 400 | 目前狀態不能建立補繳連結(只有扣款失敗且排定重試中的訂閱可以) |
| `X022` | 401 | 金鑰缺少、格式錯誤或無效 |
| `X023` | 403 | 金鑰缺少這個操作需要的 scope |
| `X011` | 404 | 路徑不存在 |
### 訂閱與方案
[Section titled “訂閱與方案”](#訂閱與方案)
| errno | HTTP | 意思 |
| ------------------------------- | ---- | ------------------------------------ |
| `SA001` | 404 | 找不到訂閱,或不屬於這個商家 |
| `SA002`、`SA003`、`SA004`、`SA006` | 409 | 目前狀態不能取消、終止、恢復或變更期間 |
| `SA005` | 409 | 目前狀態不能變更方案 |
| `SA008` | 409 | 讀取後狀態已改變,請重新查詢再操作 |
| `SA009` | 409 | 因扣款失敗暫停的訂閱,請改用補繳連結 |
| `SA010` | 503 | 暫時讀不到付款資料,可以用同一把 key 重試 |
| `SA011` | 404 | 找不到方案,或不屬於這個產品 |
| `SA012` | 400 | 已經是這個方案 |
| `SA013` | 409 | 同一筆訂閱每天(台北時間)只能成功變更方案一次 |
| `SA014` | 409 | 已有進行中的方案變更 |
| `SA015` | 409 | 扣款仍在處理或結果未定。**不代表失敗**,請稍後查詢,不要重送 |
| `SA016` | 409 | 這筆變更已經完成 |
| `SA018` | 400 | 期間結束日格式錯誤 |
| `SA019` | 422 | 期間結束日必須晚於今天 |
| `SA045` | 404 | 找不到產品 |
| `SA046` | 404 | 找不到客戶 |
| `SA047` | 422 | 首期扣款的收單機構不支援 Subscription API,請洽業務人員 |
### 退款
[Section titled “退款”](#退款)
| errno | HTTP | 意思 |
| ------- | ---- | ----------------------------- |
| `SA007` | 409 | 訂閱尚未終止,不能退款 |
| `SA021` | 409 | 終止時沒有算出可退金額 |
| `SA022` | 409 | 金額超過剩餘可退額度 |
| `SA023` | 409 | 交易本身的可退餘額不足 |
| `SA024` | 409 | 這筆交易另有處理中的扣款 |
| `SA025` | 409 | 同一筆退款仍在處理中 |
| `SA026` | 409 | 這把 key 先前的退款已失敗,要再退請換新的 key |
| `SA027` | 502 | 金流端拒絕這筆退款。先確認交易狀態,要再退請換新的 key |
| `SA028` | 503 | 退款結果未定,請用同一把 key 重試 |
### Idempotency 與 webhook
[Section titled “Idempotency 與 webhook”](#idempotency-與-webhook)
| errno | HTTP | 意思 |
| ------- | ---- | -------------------------- |
| `SA034` | 400 | 沒有帶 `Idempotency-Key` |
| `SA035` | 409 | `Idempotency-Key` 已逾期,請換新的 |
| `SA044` | 409 | 同一把 key 用在內容不同的請求 |
| `SA030` | 404 | 找不到投遞紀錄 |
| `SA031` | 409 | 這筆投遞正在處理中 |
| `SA032` | 409 | 對應的 webhook 未啟用 |
| `SA033` | 409 | 事件內容已無法取得,不能重送 |
| `SA040` | 400 | 這次操作需要重新確認卡片資訊 |
| `SA041` | 400 | 目前的付款方式不支援這次操作 |