撥款指示 API
一般商家由應援依撥款頻率自動撥款。被應援設定為指示制撥款的商家,每一期要自己送出撥款指示,應援才會把款項撥給你。撥款指示 API(Partner Payout API)讓你的系統查詢目前可以撥的交易、決定這一期撥到哪一筆、送出指示,之後用查詢或 webhook 追蹤銀行的處理結果。
- 查詢可撥交易:
GET …/available-transactions回傳這一期的結算區間、可撥金額,以及可撥交易清單(最舊的在前)。 - 送出撥款指示:
POST …/instructions用lastTransactionId指定撥到哪一筆,比它早的可撥交易會一起包含進來;也可以送ALL撥全部。這些交易會被這張指示預留,不會再出現在可撥清單;指示有結果之前,被預留的交易不能退款,要退款請先取消指示。 - 應援在執行日處理:指示從
pending變成processing(已產生銀行匯款檔),銀行回覆成功後變成executed,失敗則是failed。同一個網域一次只處理一張指示,執行日較早的先處理,其餘的可能延到下一次。 - 追蹤結果:用查詢單張指示或 webhook 取得狀態。指示
failed或cancelled時會釋放預留,仍符合撥款條件的交易會回到可撥清單,可以在下一期再送。
撥款完成後,交易在 Payment API 查詢的狀態會變成 claimed(已入帳,見 Transaction 物件)。
| 環境 | Base URL |
|---|---|
| 正式環境 | https://api.oen.tw/partner-payout/v1 |
所有路徑都以 /domain/{domain} 開頭,{domain} 是你的網域名稱。例如應援頁是 https://ming.oen.tw,就填 ming。
每個請求都要帶:
Authorization: Bearer <access token>- access token 由應援的 OAuth 服務(
https://openapi.oen.tw)核發。開通時,應援會為你建立帶merchant:payout權限的 OAuth client,並提供取得與更新 token 的方式。 - 完成授權的 CRM 帳號必須是這個網域的管理員。
- 以下情況一律回 401
X022:token 無效或過期、沒有merchant:payout權限、授權帳號不是{domain}的管理員,或應援已停用這個 OAuth client、移除它的權限。後兩種情況重新取得 token 也無法解決,請聯絡應援確認開通狀態。 - CRM 產生的 Payment API token 不能用在這支 API。
- 請求與回應都是 JSON(UTF-8)。金額是新台幣整數(元),
currency一律是TWD。 - 時間格式是 ISO 8601。
submittedAt、executedAt是 UTC(結尾Z);結算區間的start、end帶台北時區(+08:00);executionDate是台北日期yyyy-MM-dd。請用一般的 ISO 8601 解析,不要假設一定以Z結尾。
成功的回應一律包在 data 裡。單筆資料是物件,指示列表是陣列,並多一個 paging:
{ "data": { "instructionId": "pi_2sTk9Yx3Qm7bV1nWc4Rz8Lp0FhA", "status": "pending", "...": "..." } }{ "data": [ { "instructionId": "pi_2sTk9Yx3Qm7bV1nWc4Rz8Lp0FhA", "...": "..." } ], "paging": { "next": null } }業務錯誤帶 error 與 errno,部分錯誤另有 detail。正式環境不回 message。
{ "error": "instruction_already_exists", "errno": "PY003"}少數錯誤沒有 errno,請一定也看 HTTP 狀態碼:
- body 欄位格式不對時回 400,
detail.fieldValidationErrors會指出是哪個欄位。 - body 不是合法的 JSON 時回 400
X027。 - PUT、PATCH 等這支 API 完全不接受的 method 回 405
{ "error": "method_not_allowed" };GET、POST、DELETE 用在不支援的路徑(例如POST …/available-transactions)則回 404X024。
各個 errno 的意思見錯誤碼。
結算區間與執行日
Section titled “結算區間與執行日”指示制撥款每週撥款一次,執行日是星期五;星期五遇到銀行休息日時,順延到下一個銀行營業日。
| 送出指示的時間(台北時間) | 執行日 |
|---|---|
| 星期四 12:00:00 以前 | 當週星期五 |
| 星期四 12:00:00 起(含週末) | 下週星期五 |
- 每張指示都屬於一個結算區間:以這一期原定的星期五計算,從往前 8 天的 00:00 到往前 2 天的 23:59:59.999(星期四到星期三)。回應的
settlementWindow就是這個區間。 - 銀行休息日只會順延
executionDate,不會改變結算區間與星期四中午的截止時間。例如 2026 年 10 月 9 日(星期五)銀行休息,那一期的執行日是 10 月 12 日,結算區間仍是 10 月 1 日到 10 月 7 日。 - 每個網域在同一個結算區間只能有一張指示,已取消或失敗的指示也算。同一期再送會被拒絕,通常回 409
PY003;如果交易都已被第一張預留,可能會先回PY004或PY007。交易要等下一期再送。 - 結算區間只用來標示「這是哪一期的指示」,不限制可以撥哪些交易。可撥的交易見下一節。
可撥交易與可撥金額
Section titled “可撥交易與可撥金額”哪些交易可以撥
Section titled “哪些交易可以撥”可撥清單裡的交易同時符合:
- 狀態是
charged(付款成功、等待撥款)。 - 還沒撥款、沒有被其他指示預留,也沒有進行中或結果未定的退款。
- 已過保留期:交易建立時間(
sortAt)在「今天往前 7 天的台北時間 0 點」(含)以前。例如 10 月 1 日白天建立的交易,10 月 9 日起才會出現在清單。政治獻金擬參選人的網域是 3 天。
清單依 sortAt 由舊到新排序。還在保留期的交易不會出現;拿它當 lastTransactionId 會回 400 PY006。
balance 欄位
Section titled “balance 欄位”| 欄位 | 說明 |
|---|---|
gross |
可撥交易的金額合計,加上符合條件的「已退款、還沒撥款」交易的原始金額。這類交易要有退款時間紀錄,而且退款時間在「今天往前 7 天的台北時間 0 點」(含)以前才計入;款項由收單機構直接撥給你的付款方式(例如 LINE Pay、藍新)不列入 |
reserved |
已被指示預留、還沒撥款的交易金額合計,不計入 netAvailable |
adjustmentDeduction |
撥款後才退款、還沒從撥款扣回的金額。這筆扣回紀錄本身也要過了保留期才會計入,剛產生的不會立即反映 |
netAvailable |
gross 扣掉上述交易的退款金額,再扣 adjustmentDeduction。可能是 0 或負數 |
currency |
TWD |
partial |
true 表示這次沒有讀完(交易太多或讀取超過時間),清單只有最舊的一部分,金額可能偏高或偏低。false 才表示這次讀完了 |
- 即使
partial是false,金額仍受上述時間條件限制:adjustmentDeduction是 0 不代表所有待扣回的款項都已結清,較早的歷史退款也可能不在這個試算裡。對金額有疑問請洽應援。 - 部分退款後剩下的金額,符合上述條件才會計入
netAvailable;這些交易不在可撥清單、不能逐筆指定,所以netAvailable可能和清單金額合計不同。netAvailable只是查詢當下的試算,不是實際匯款金額。 - 這次算出的
adjustmentDeduction大於 0 時,目前無法送出指示:送出會回 422PY004,detail.reason是unpaid_adjustments。請聯絡應援處理。 partial是true時,見讀取不完整時。
| Method | 路徑 | 說明 |
|---|---|---|
GET |
/domain/{domain}/available-transactions |
查詢可撥交易 |
POST |
/domain/{domain}/instructions |
送出撥款指示 |
GET |
/domain/{domain}/instructions |
查詢指示列表 |
GET |
/domain/{domain}/instructions/{instructionId} |
查詢單張指示 |
DELETE |
/domain/{domain}/instructions/{instructionId} |
取消指示 |
各端點列出的錯誤之外,都可能回 X022、X030、5xx,以及共通規格提到的格式錯誤。
查詢可撥交易
Section titled “查詢可撥交易”GET /domain/{domain}/available-transactions
curl "https://api.oen.tw/partner-payout/v1/domain/ming/available-transactions" \ -H "Authorization: Bearer $OEN_PAYOUT_TOKEN"回應 200:
{ "data": { "settlementWindow": { "start": "2026-09-03T00:00:00.000+08:00", "end": "2026-09-09T23:59:59.999+08:00", "executionDate": "2026-09-11" }, "balance": { "gross": 14300, "reserved": 3500, "adjustmentDeduction": 0, "netAvailable": 13800, "currency": "TWD", "partial": false }, "transactions": [ { "transactionId": "2sTjQ8vLx0Kq9Wm3Yb7Rn1Zc5Fd", "orderId": "O0820K3M7P2QX", "amount": 1200, "paidAt": "2026-08-20T03:12:44.000Z", "sortAt": "2026-08-20T03:12:40.512Z", "status": "charged" }, { "transactionId": "2sTnB4cHy2Pw6Vd8Lq1Xr9Ma3Ge", "orderId": "O0824R1S8T4VZ", "amount": 3600, "paidAt": "2026-08-24T10:05:02.000Z", "sortAt": "2026-08-24T10:04:58.107Z", "status": "charged" }, { "transactionId": "2sTpF7dJz5Rt2Wg4Nk8Ys6Bh1Ci", "orderId": "O0827W6X2Y9AB", "amount": 8000, "paidAt": "2026-08-27T14:41:19.000Z", "sortAt": "2026-08-27T14:41:15.330Z", "status": "charged" } ], "nextCursor": null }}| 欄位 | 說明 |
|---|---|
settlementWindow |
現在送出指示會歸到的結算區間與執行日 |
balance |
見 balance 欄位 |
transactions[].transactionId |
交易的內部 id(27 字元),跟 Payment API Transaction 物件的 transactionId 相同,不是 P 開頭的 id |
transactions[].orderId |
交易上的訂單編號。用 Payment API 建立的交易是你帶的 orderId;其他來源的交易可能沒有這個欄位 |
transactions[].amount |
交易金額 |
transactions[].paidAt |
付款時間;沒有記錄時欄位不會出現 |
transactions[].sortAt |
交易建立時間(UTC)。清單排序與保留期都以這個欄位為準 |
transactions[].status |
一律是 charged |
nextCursor |
目前固定是 null,這個端點沒有分頁。是否讀完請看 balance.partial |
可能的錯誤:PY002、PY009、X024。
送出撥款指示
Section titled “送出撥款指示”POST /domain/{domain}/instructions
Header 除了 Authorization,還要帶 Content-Type: application/json 與 Idempotency-Key(見 Idempotency-Key)。
| Body 欄位 | 必填 | 說明 |
|---|---|---|
lastTransactionId |
必填 | 撥到哪一筆交易(含)。比它早的可撥交易會全部包含,不能跳過。送 ALL 表示可撥清單全部。1 到 128 字元,不能帶其他欄位 |
curl -X POST "https://api.oen.tw/partner-payout/v1/domain/ming/instructions" \ -H "Authorization: Bearer $OEN_PAYOUT_TOKEN" \ -H "Idempotency-Key: 9f1c2a7e-4b1d-4e2a-bc77-2f0e1d3a9c01" \ -H "Content-Type: application/json" \ -d '{ "lastTransactionId": "2sTnB4cHy2Pw6Vd8Lq1Xr9Ma3Ge" }'回應 201,data 是建立好的指示:
{ "data": { "instructionId": "pi_2sTk9Yx3Qm7bV1nWc4Rz8Lp0FhA", "status": "pending", "settlementWindow": { "start": "2026-09-03T00:00:00.000+08:00", "end": "2026-09-09T23:59:59.999+08:00", "executionDate": "2026-09-11" }, "executionDate": "2026-09-11", "transactionIds": ["2sTjQ8vLx0Kq9Wm3Yb7Rn1Zc5Fd", "2sTnB4cHy2Pw6Vd8Lq1Xr9Ma3Ge"], "transactionCount": 2, "totalAmount": 4800, "currency": "TWD", "submittedAt": "2026-09-05T02:30:11.412Z", "executedAt": null, "failureCode": null, "failureMessage": null }}送出時依下列順序檢查,前面的檢查沒通過,就不會回後面的錯誤:
- 網域設定:不是指示制撥款
PY002、撥款未啟用或被暫停PY011、撥款頻率不是每週五PY009。 - 來源 IP 頻率限制
X030。 Idempotency-Key:缺少或格式不對X034、內容不符PO004、處理中PO005;同一把 key 已有結果就直接回那個結果。- 網域的頻率限制
X030。 - 有撥款後退款還沒扣回:
PY004(detail.reason是unpaid_adjustments)。 lastTransactionId:ALL:讀取不完整時PY013;沒有可撥交易時PY004。- 指定交易:查不到或不是這個網域的交易
PY005;還在保留期PY006;讀取不完整、無法確認時PY013;確定不能撥(已退款、退款處理中、已預留或已撥款)PY007,detail帶ineligibleId與status。 - 超過 99 筆
PY008:請改用較早的交易當lastTransactionId,其餘留到下一期。
- 建立指示:這一期已經有指示
PY003;有交易在這一刻剛被撥款、退款或預留PY010,detail.transactionId是那一筆。
讀取不完整時
Section titled “讀取不完整時”交易很多或讀取太久時,系統只會讀到最舊的一部分(balance.partial 是 true)。這時:
- 送
ALL會回 422PY013。 - 指定的交易如果無法確認在已讀到的範圍內,也會回
PY013。 PY013的detail帶readableCount(讀到的可撥筆數)、lastReadableTransactionId、lastReadableSortAt;讀到 0 筆時後兩個是null。- 請重新查詢可撥交易,用清單裡第 99 筆或更早的交易當
lastTransactionId,其餘留到下一期,或稍後再試。PY013可以用同一把Idempotency-Key重送。
查詢指示列表
Section titled “查詢指示列表”GET /domain/{domain}/instructions?limit={limit}&page={page}
| 查詢參數 | 說明 |
|---|---|
limit |
每頁幾張,1 到 500,預設 100 |
page |
下一頁的游標,原樣帶回上一頁回應的 paging.next。第一頁不用帶,也不要自己組 |
由新到舊排列。列表裡的指示不含 transactionIds,要看交易明細請用查詢單張指示。paging.next 是 null 時表示沒有下一頁。
{ "data": [ { "instructionId": "pi_2sTk9Yx3Qm7bV1nWc4Rz8Lp0FhA", "status": "pending", "settlementWindow": { "start": "2026-09-03T00:00:00.000+08:00", "end": "2026-09-09T23:59:59.999+08:00", "executionDate": "2026-09-11" }, "executionDate": "2026-09-11", "transactionCount": 2, "totalAmount": 4800, "currency": "TWD", "submittedAt": "2026-09-05T02:30:11.412Z", "executedAt": null, "failureCode": null, "failureMessage": null } ], "paging": { "next": null }}可能的錯誤:X024、X028、X034。
查詢單張指示
Section titled “查詢單張指示”GET /domain/{domain}/instructions/{instructionId}
回應 200,data 是完整的指示(含 transactionIds)。不是這個網域的指示一律回 404 PY001。
{ "data": { "instructionId": "pi_2sQe4Hb8Wn1Ks6Zt3Mv9Xc2RaAB", "status": "failed", "settlementWindow": { "start": "2026-08-27T00:00:00.000+08:00", "end": "2026-09-02T23:59:59.999+08:00", "executionDate": "2026-09-04" }, "executionDate": "2026-09-04", "transactionIds": ["2sQa1Lm4Kp7Xw2Nc9Vb3Rt6Yh8J", "2sQb2Hn5Jq8Zx3Md1Wc4Su7Ti9K"], "transactionCount": 2, "totalAmount": 21500, "currency": "TWD", "submittedAt": "2026-08-31T01:02:03.000Z", "executedAt": null, "failureCode": "BANK_REJECTED", "failureMessage": "帳號錯誤" }}可能的錯誤:PY001、X024。
DELETE /domain/{domain}/instructions/{instructionId}
- 只有
pending的指示可以取消。取消後狀態變成cancelled,預留會釋放,仍符合撥款條件的交易回到可撥清單。 - 已經是
cancelled的指示再取消,會回 200 與目前的內容,可以安心重送。 processing、executed、failed的指示不能取消,回 409PY012。指示剛好在取消時被應援開始處理,也會回PY012,請重新查詢指示確認狀態。- 取消後,同一個結算區間不能再送新指示,交易要等下一期(見結算區間與執行日)。
- 取消前同樣會檢查網域設定(
PY002、PY011)。不需要Idempotency-Key。
回應 200,data 是取消後的指示,status 是 cancelled。
可能的錯誤:PY001、PY002、PY011、PY012、X024。
| 欄位 | 說明 |
|---|---|
instructionId |
指示編號,pi_ 開頭 |
status |
指示狀態,見下表 |
settlementWindow |
結算區間:start、end、executionDate |
executionDate |
預定執行日,台北日期 yyyy-MM-dd。不代表當天一定處理完成,請以 status 為準 |
transactionIds |
這張指示包含的交易編號。列表不回這個欄位 |
transactionCount |
交易筆數 |
totalAmount |
這些交易的金額合計,不是實際匯款金額 |
currency |
TWD |
submittedAt |
送出時間 |
executedAt |
撥款成功的時間;還沒成功時是 null |
failureCode |
失敗原因代碼,見下表;沒有失敗時是 null |
failureMessage |
失敗說明;銀行拒絕時是銀行回覆的原因 |
| 狀態 | 說明 | 之後可能變成 |
|---|---|---|
pending |
已送出,等待執行日處理。可以取消 | processing、failed、cancelled |
processing |
應援已產生銀行匯款檔,等待銀行回覆 | executed、failed |
executed |
撥款成功 | 不會再變 |
failed |
撥款失敗,預留已釋放 | 不會再變 |
cancelled |
你取消了這張指示,預留已釋放 | 不會再變 |
failureCode
Section titled “failureCode”| 代碼 | 發生在 | 說明 |
|---|---|---|
REVALIDATION_FAILED |
pending |
執行日重新檢查時,指示裡的交易已不符合撥款條件(例如已退款) |
AMOUNT_BELOW_MINIMUM |
pending |
實際匯款金額(扣除手續費、加計部分退款剩餘金額之後)低於 60 元。送出指示時不會檢查這一項 |
SFTP_FAILED |
processing |
匯款檔沒有送達銀行 |
BANK_REJECTED |
processing |
銀行拒絕匯款,原因見 failureMessage |
失敗的指示不會自動重送。仍符合撥款條件的交易回到可撥清單後,請在下一期重新送出;銀行拒絕通常跟收款帳戶有關,請先確認撥款帳戶資料。
Idempotency-Key
Section titled “Idempotency-Key”送出撥款指示一定要帶 Idempotency-Key,沒帶或格式不對會回 400 X034。
- 格式:1 到 255 個可見 ASCII 字元,不能有空白。
- 每一張新指示都要用新的 key,例如新產生一組 UUID。key 不會過期,下週沿用同一把 key 再送
ALL,只會拿到上次的結果。 - 同一把 key、同樣的內容重送,會回第一次保存的結果(原本的 HTTP 狀態碼與當時的指示內容),包括第一次回的業務錯誤(例如
PY003到PY008、PY010),不會重複建立指示。重送的回應是當時的快照,指示目前的狀態請用查詢取得。 - 重送前仍會先做檢查順序的第 1 到 2 步,例如網域在這段時間被暫停,重送會回
PY011。 - 同一把 key、不同的內容,回 409
PO004。 - 第一次的請求還在處理中,回 409
PO005,稍後用同一把 key 再送。超過約 5 分鐘還沒有結果,下一次重送會重新處理。 - 以下情況不會保存結果,可以用同一把 key 重送:429
X030、422PY013、沒有errno的系統錯誤。 - 逾時或收到 5xx 時,先用查詢指示列表確認這一期有沒有建立指示,再用同一把 key 重送,不要直接換新 key。
- 因為業務錯誤被拒絕、修正後要重新送出時,請用新的 key。
Webhook
Section titled “Webhook”指示狀態改變時,應援會送出 webhook。接收網址與簽章密鑰由應援在開通時幫你設定;設定完成前,請用查詢單張指示確認狀態。
Webhook 可能重複、延遲,少數情況也可能沒有送達,到達的順序也不一定等於狀態改變的順序。請保留定期查詢指示的對帳機制,不要讓晚到的 processing 覆蓋已經是終態(executed、failed、cancelled)的指示。
| 事件 | 什麼時候 |
|---|---|
payout.instruction.processing |
應援產生了銀行匯款檔 |
payout.instruction.executed |
撥款成功 |
payout.instruction.failed |
撥款失敗,原因見 failureCode、failureMessage |
payout.instruction.cancelled |
指示被取消 |
送出指示(pending)不會發 webhook,建立結果直接看 API 回應。
內容與 header
Section titled “內容與 header”POST https://shop.example.com/oen/payout-webhookContent-Type: application/jsonOenPay-Event-Type: payout.instruction.executedOenPay-Event-Id: evt_4f3a9c1d2e8b7a6f5c4d3e2fOenPay-Signature: t=1789124412,v1=<HMAC-SHA256 簽章>{ "id": "evt_4f3a9c1d2e8b7a6f5c4d3e2f", "type": "payout.instruction.executed", "api_version": "2026-06-01", "created": 1789124412, "data": { "instructionId": "pi_2sTk9Yx3Qm7bV1nWc4Rz8Lp0FhA", "domainId": "2aB3cD4eF5gH6iJ7kL8mN9oP0qR", "domain": "ming", "status": "executed", "totalAmount": 4800, "currency": "TWD", "executionDate": "2026-09-11", "executedAt": "2026-09-11T11:00:12.000Z", "failureCode": null, "failureMessage": null, "transactionCount": 2, "occurredAt": "2026-09-11T11:00:12.000Z" }}| 欄位 | 說明 |
|---|---|
id |
事件編號,evt_ 開頭 |
type |
事件名稱,同 OenPay-Event-Type |
api_version |
事件格式版本 |
created |
事件建立時間(Unix 秒)。重試時不變;驗證簽章請用 header 裡的 t |
data.instructionId、data.status |
哪一張指示、變成什麼狀態 |
data.domain、data.domainId |
網域名稱與網域編號 |
data.totalAmount、data.currency、data.executionDate、data.transactionCount |
同指示物件 |
data.executedAt、data.failureCode、data.failureMessage |
同指示物件,沒有值時是 null |
data.occurredAt |
狀態改變的時間 |
data 只有上面這些欄位,不帶 transactionIds、settlementWindow、submittedAt;需要時請用查詢單張指示。除了 instructionId、domainId、status、transactionCount、occurredAt,其他欄位都可能是 null。
重試時沿用原本的 id、created 與 body,另外加上 OenPay-Retry-Count header;簽章與 t 會重新產生。
避免重複處理
Section titled “避免重複處理”同一個狀態改變可能送到不只一次,而且每次的 id 可能不同。請以 data.instructionId 加 data.status 判斷是否處理過:
id相同:同一次投遞的重送。id不同,但instructionId與status相同:仍是同一個狀態改變,不要重複入帳。
驗證簽章通過後,先把事件與已處理紀錄可靠地存下來,再回 2xx,耗時的處理放到背景做;存檔失敗就回非 2xx,讓應援重試。
OenPay-Signature 的格式是 t=<Unix 秒>,v1=<hex>,簽章是以簽章密鑰對 `${t}.${原始 body}` 做 HMAC-SHA256。驗證簽章時要用收到的原始 body,並檢查 t 與現在相差不超過 5 分鐘。更換密鑰期間會帶兩個 v1=,任一個相符就通過。
# 在本機模擬一個帶簽章的通知,測試你的驗簽程式(測試資料,不是實際的通知)BODY='{"id":"evt_test_0001","type":"payout.instruction.executed","api_version":"2026-06-01","created":1790000000,"data":{"instructionId":"pi_test_0001","domainId":"test_domain_id","domain":"ming","status":"executed","totalAmount":4800,"currency":"TWD","executionDate":"2026-09-11","executedAt":"2026-09-11T11:00:12.000Z","failureCode":null,"failureMessage":null,"transactionCount":2,"occurredAt":"2026-09-11T11:00:12.000Z"}}'T=$(date +%s)SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$OEN_PAYOUT_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "http://localhost:3000/webhooks/oen" \ -H "Content-Type: application/json" \ -H "OenPay-Event-Type: payout.instruction.executed" \ -H "OenPay-Event-Id: evt_test_0001" \ -H "OenPay-Signature: t=$T,v1=$SIG" \ -d "$BODY"import crypto from "node:crypto";import express from "express";
const app = express();const TOLERANCE_SECONDS = 300;const SECRET = process.env.OEN_PAYOUT_WEBHOOK_SECRET;// 沒有密鑰就不啟動:用空字串驗簽,任何人都能算出正確的簽章if (!SECRET) throw new Error("OEN_PAYOUT_WEBHOOK_SECRET 未設定");
// OenPay-Signature: t=<unix 秒>,v1=<hex>[,v1=<hex>],簽的是 `${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); });}
// 請接上你的資料庫或可靠的工作佇列:以 event.data.instructionId 加 event.data.status 為唯一鍵保存事件,// 已保存過也算成功;保存失敗要拋出錯誤。完成實作前請維持拋錯,不要回報成功。async function saveEventOnce(event) { throw new Error("saveEventOnce 尚未實作");}
// 驗簽要用原始 body,不要先 JSON.parseapp.post("/webhooks/oen", express.raw({ type: "application/json" }), async (req, res) => { const rawBody = req.body.toString("utf8"); if (!verifySignature(rawBody, req.header("OenPay-Signature"), SECRET)) { return res.status(400).send("invalid signature"); } const event = JSON.parse(rawBody); try { await saveEventOnce(event); // 存好才回 200 } catch { return res.sendStatus(503); // 存檔失敗,讓應援重試 } res.sendStatus(200); // 出貨、寄信等耗時工作由背景程序處理已保存的事件});<?php$payload = file_get_contents('php://input');$header = $_SERVER['HTTP_OENPAY_SIGNATURE'] ?? '';$secret = getenv('OEN_PAYOUT_WEBHOOK_SECRET');// 沒有密鑰就不處理:用空字串驗簽,任何人都能算出正確的簽章if ($secret === false || $secret === '') { http_response_code(503); exit('webhook secret not configured');}
// OenPay-Signature: t=<unix 秒>,v1=<hex>[,v1=<hex>],簽的是 "{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['data']['instructionId'] 加 $event['data']['status'] 為唯一鍵保存事件,// 已保存過也算成功;保存失敗要拋出例外。完成實作前請維持拋出例外,不要回報成功。function save_event_once(array $event): void { throw new RuntimeException('save_event_once 尚未實作');}
$event = json_decode($payload, true);try { save_event_once($event); // 存好才回 200} catch (Throwable $error) { http_response_code(503); // 存檔失敗,讓應援重試 exit;}http_response_code(200);import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
app = Flask(__name__)SECRET = os.environ.get("OEN_PAYOUT_WEBHOOK_SECRET", "")# 沒有密鑰就不啟動:用空字串驗簽,任何人都能算出正確的簽章if not SECRET: raise RuntimeError("OEN_PAYOUT_WEBHOOK_SECRET 未設定")
# OenPay-Signature: t=<unix 秒>,v1=<hex>[,v1=<hex>],簽的是 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)
# 請接上你的資料庫或可靠的工作佇列:以 event["data"]["instructionId"] 加 event["data"]["status"] 為唯一鍵保存事件,# 已保存過也算成功;保存失敗要拋出例外。完成實作前請維持拋出例外,不要回報成功。def save_event_once(event: dict) -> None: raise NotImplementedError("save_event_once 尚未實作")
@app.post("/webhooks/oen")def oen_webhook(): payload = request.get_data() if not verify_signature(payload, request.headers.get("OenPay-Signature", ""), SECRET): abort(400) event = request.get_json() try: save_event_once(event) # 存好才回 200 except Exception: return "", 503 # 存檔失敗,讓應援重試 return "", 200- 回 2xx、並且完整收到回應才算成功。回應 body 請保持精簡(例如回 204 或很短的 200),超過 2048 bytes 也算失敗。
- 不會跟隨轉址(3xx 算失敗);連線或回應超過 30 秒沒有動靜也算失敗。
- 每個事件最多投遞 5 次(第一次加上 4 次重試)。每次失敗後,分別約 5 分鐘、30 分鐘、2 小時、24 小時再試一次,實際時間可能稍晚。
- 接收網址必須是 https,不能指向內部網路位址。
| errno | HTTP | error |
意思 | 建議處理 |
|---|---|---|---|---|
PY001 |
404 | instruction_not_found |
找不到這張指示,或不是這個網域的指示 | 確認 instructionId 與網域 |
PY002 |
400 | mode_not_instruction_required |
網域不是指示制撥款 | 聯絡應援 |
PY003 |
409 | instruction_already_exists |
這個結算區間已經有指示(含已取消、失敗的) | 下一期再送 |
PY004 |
422 | insufficient_net_available |
送 ALL 但沒有可撥交易;或有撥款後退款還沒扣回(detail.reason 是 unpaid_adjustments) |
沒有可撥交易就等下一期;unpaid_adjustments 請聯絡應援 |
PY005 |
404 | transaction_not_found |
lastTransactionId 查不到,或不是這個網域的交易 |
改用可撥清單裡的 transactionId |
PY006 |
400 | transaction_out_of_window |
lastTransactionId 的交易還在保留期 |
改用可撥清單裡的 transactionId |
PY007 |
422 | cursor_skips_earlier_transactions |
lastTransactionId 的交易不能撥(已退款、已預留或已撥款),detail 帶 ineligibleId、status |
重新查詢可撥交易後,用新的 key 再送 |
PY008 |
422 | too_many_transactions_in_range |
超過 99 筆 | 改用較早的交易當 lastTransactionId |
PY009 |
400 | settlement_window_unavailable |
撥款頻率不是每週五 | 聯絡應援 |
PY010 |
409 | transaction_not_reservable |
送出當下有交易剛被撥款、退款或被其他指示預留,detail.transactionId 是那一筆 |
重新查詢可撥交易,用新的 key 再送 |
PY011 |
403 | payout_not_enabled |
這個網域的撥款沒有啟用或已被暫停(送出與取消時檢查) | 聯絡應援 |
PY012 |
409 | instruction_not_cancellable |
指示不是 pending,不能取消(已經是 cancelled 的會直接回 200) |
查詢指示目前的狀態 |
PY013 |
422 | eligible_set_partial |
讀取不完整,無法處理這個 lastTransactionId;detail 帶 readableCount、lastReadableTransactionId、lastReadableSortAt |
見讀取不完整時 |
PO004 |
409 | idempotency_key_reused |
同一把 Idempotency-Key 用在不同的請求內容 |
換一把新的 key |
PO005 |
409 | idempotency_key_in_progress |
同一把 key 的請求還在處理中 | 稍後用同一把 key 再送 |
X022 |
401 | Unauthorized |
token 無效、過期或權限不足,見驗證 | 重新取得 token;仍失敗請聯絡應援 |
X024 |
404 | Not Found |
網域不存在或路徑錯誤 | 確認 {domain} 與路徑 |
X027 |
400 | JSON_BODY_PARSE_FAILED |
body 不是合法的 JSON | 修正 body |
X028 |
400 | PAGINATION_PARSE_FAILED |
page 參數無法解析 |
原樣帶回上一頁的 paging.next |
X030 |
429 | rate_limit_exceeded |
超過頻率限制 | 見頻率限制 |
X034 |
400 | Bad Request |
缺少或格式不對的 Idempotency-Key、limit 超出範圍,或 page 不屬於這個網域 |
修正請求內容 |
| (無) | 405 | method_not_allowed |
這支 API 完全不接受的 HTTP method(例如 PUT、PATCH) | 確認 method 與路徑 |
| 端點 | 每個網域每小時 |
|---|---|
| 查詢可撥交易 | 120 次 |
| 送出撥款指示 | 30 次 |
| 查詢指示列表 | 300 次 |
| 查詢單張指示 | 300 次 |
| 取消指示 | 30 次 |
- 每個端點的額度分開計算。另外,每個來源 IP 每分鐘最多 60 次,這個額度與應援其他合作夥伴 API 共用。
- 超過時回 429
X030,header 帶Retry-After(建議等待的秒數)與X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset(Unix 秒)。等待後仍可能超過,請逐步拉長間隔再試。 - 正常的回應通常也會帶
X-RateLimit-*header,數字是當下最接近上限的那一項(可能是 IP,也可能是網域)。這些 header 偶爾會沒有,程式請不要依賴它們一定存在。
導覽助手暫停服務中。請使用右上角的搜尋,或聯絡客服。
我可以幫你選擇收款方式、回答 API 串接與 CRM 操作問題,並附上文件出處。
