付款通知(webhook)
付款結果確定時,應援會 POST 一則通知到你的伺服器。請把它當成「提醒你去查」,以回查的結果為準。
通知網址只能在 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 |
需請應援開啟 |
| API 交易的電子發票開立成功、作廢成功 | invoice_issued、invoice_voided |
需請應援開啟,見電子發票號碼與狀態 |
| 消費者在換卡頁換卡成功 | subscription_card_changed |
— |
不會送通知的情況:
- 消費者沒有付款就離開,交易停在
initiated。 - 用 token 扣款、用 token 建立定期定額的第一期。結果以 API 回應為準。
- 付款前的檢查就失敗(例如金額超出付款方式的範圍)。
每一種通知的欄位見付款通知內容。
| 項目 | 規則 |
|---|---|
| 格式 | POST,Content-Type: application/json |
| 逾時 | 每次 10 秒 |
| 成功的條件 | 回應 HTTP 2xx。不看回應內容 |
| 轉址 | 不跟隨。回 3xx 會被當成失敗 |
| 次數 | 最多 3 次,間隔約 2 秒、4 秒 |
| 3 次都失敗 | 不會再送,也沒有後台重送功能 |
| 順序 | 不保證 |
確認通知的真假
Section titled “確認通知的真假”在 CRM 產生「付款通知簽章密鑰」後,每則付款通知都會帶 OenPay-Signature header,可以用它確認通知真的是應援送的、內容沒有被改過。還沒產生密鑰時,通知不帶簽章,任何人都可以偽造一則送到你的網址,請一定要回查。
產生簽章密鑰
Section titled “產生簽章密鑰”- 進入 CRM「總設定」→「開發者」→「應援金流設定」,找到「付款通知簽章密鑰」。
- 按「產生簽章密鑰」。密鑰以
pay_whsec_開頭,只會顯示這一次,請立刻存到伺服器的環境變數或密鑰管理服務。 - 下一則通知起就會帶簽章。測試環境與正式環境的密鑰分開產生。
OenPay-Signature 的格式是 t=<unix 秒>,v1=<hex>:
- 取得還沒解析的原始 body。先
JSON.parse再轉回字串會改變內容,簽章一定對不上。 - 從 header 拆出
t與v1。 - 用完整的
pay_whsec_字串當 key,對{t}.{原始 body}計算 HMAC-SHA256,輸出十六進位,用固定時間比較函式和v1比對。 - 建議拒收
t和現在相差超過 5 分鐘的通知。 - 驗證失敗回 4xx,不處理這則通知。
已產生密鑰時,應援不會改送沒有簽章的通知;收到沒有 OenPay-Signature 的通知請直接拒收。
# 在本機模擬一個帶簽章的通知,測試你的驗簽程式(測試資料,不是實際的通知)BODY='{"purpose":"charge","action":"onetime","merchantId":"ming","orderId":"A20260928001","id":"2HhndgEquCbDzC5OyVxWSGZmd2l","transactionId":"2HhndgEquCbDzC5OyVxWSGZmd2l","transactionHid":"P20260928AB12CD34","status":"charged","success":true,"paymentMethod":"card","amount":1200,"currency":"twd","message":""}'T=$(date +%s)SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$OEN_PAYMENT_WEBHOOK_SECRET" | sed 's/^.* //')EVENT_ID="evt_$(printf '%s' '["charge","ming","2HhndgEquCbDzC5OyVxWSGZmd2l","charged",true]' | openssl dgst -sha256 | sed 's/^.* //' | cut -c1-32)"
curl -X POST "http://localhost:3000/webhooks/oen" \ -H "Content-Type: application/json" \ -H "OenPay-Event-Id: $EVENT_ID" \ -H "OenPay-Delivery-Attempt: 1" \ -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_PAYMENT_WEBHOOK_SECRET;// 沒有密鑰就不啟動:用空字串驗簽,任何人都能算出正確的簽章if (!SECRET) throw new Error("OEN_PAYMENT_WEBHOOK_SECRET 未設定");
// OenPay-Signature: t=<unix 秒>,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(); // 付款通知只會帶一個 v1=(輪替密鑰後舊密鑰立即失效) return sigs.some((sig) => { const received = Buffer.from(sig, "hex"); return received.length === expected.length && crypto.timingSafeEqual(received, expected); });}
// 事件 ID:evt_ + sha256(JSON.stringify([purpose, merchantId, id, status, success])) 的前 32 個十六進位字元。// 五個值依序取自已驗證 body 的 purpose、merchantId、id、status、success,缺少的欄位帶 null。// OenPay-Event-Id header 不在簽章範圍內,一律用這裡重算的值防重複。function eventIdFromBody(event) { const identity = JSON.stringify([ event.purpose ?? null, event.merchantId ?? null, event.id ?? null, event.status ?? null, event.success ?? null, ]); return `evt_${crypto.createHash("sha256").update(identity, "utf8").digest("hex").slice(0, 32)}`;}
// 請接上你的資料庫或可靠的工作佇列:以 eventId(由已驗證的 body 重算) 為唯一鍵保存事件,// 已保存過也算成功;保存失敗要拋出錯誤。完成實作前請維持拋錯,不要回報成功。async function saveEventOnce(eventId, 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); const eventId = eventIdFromBody(event); // header 的 OenPay-Event-Id 和重算的值不同,代表被竄改,拒收 const headerEventId = req.header("OenPay-Event-Id"); if (headerEventId && headerEventId !== eventId) { return res.status(400).send("event id mismatch"); } try { await saveEventOnce(eventId, 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_PAYMENT_WEBHOOK_SECRET');// 沒有密鑰就不處理:用空字串驗簽,任何人都能算出正確的簽章if ($secret === false || $secret === '') { http_response_code(503); exit('webhook secret not configured');}
// OenPay-Signature: t=<unix 秒>,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');}
// 事件 ID:evt_ + sha256([purpose, merchantId, id, status, success] 的 JSON) 的前 32 個十六進位字元,缺少的欄位帶 null。// JSON 要和 JavaScript 的 JSON.stringify 逐位元組相同:不跳脫 /、中文與 U+2028/U+2029。function event_id_from_body(array $event): string { $identity = json_encode([ $event['purpose'] ?? null, $event['merchantId'] ?? null, $event['id'] ?? null, $event['status'] ?? null, $event['success'] ?? null, ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS | JSON_THROW_ON_ERROR); return 'evt_' . substr(hash('sha256', $identity), 0, 32);}
// 請接上你的資料庫或可靠的工作佇列:以 $eventId(由已驗證的 body 重算) 為唯一鍵保存事件,// 已保存過也算成功;保存失敗要拋出例外。完成實作前請維持拋出例外,不要回報成功。function save_event_once(string $eventId, array $event): void { throw new RuntimeException('save_event_once 尚未實作');}
$event = json_decode($payload, true);$eventId = event_id_from_body($event);// header 的 OenPay-Event-Id 不在簽章範圍內:和重算的值不同就拒收$headerEventId = $_SERVER['HTTP_OENPAY_EVENT_ID'] ?? '';if ($headerEventId !== '' && !hash_equals($eventId, $headerEventId)) { http_response_code(400); exit('event id mismatch');}try { save_event_once($eventId, $event); // 存好才回 200} catch (Throwable $error) { http_response_code(503); // 存檔失敗,讓應援重試 exit;}http_response_code(200);import hashlibimport hmacimport jsonimport osimport time
from flask import Flask, abort, request
app = Flask(__name__)SECRET = os.environ.get("OEN_PAYMENT_WEBHOOK_SECRET", "")# 沒有密鑰就不啟動:用空字串驗簽,任何人都能算出正確的簽章if not SECRET: raise RuntimeError("OEN_PAYMENT_WEBHOOK_SECRET 未設定")
# OenPay-Signature: t=<unix 秒>,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)
# 事件 ID:evt_ + sha256([purpose, merchantId, id, status, success] 的 JSON) 的前 32 個十六進位字元,缺少的欄位帶 None(null)。# JSON 要和 JavaScript 的 JSON.stringify 逐位元組相同:不加空白、不跳脫中文。def event_id_from_body(event: dict) -> str: identity = json.dumps( [event.get("purpose"), event.get("merchantId"), event.get("id"), event.get("status"), event.get("success")], separators=(",", ":"), ensure_ascii=False, ) return "evt_" + hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32]
# 請接上你的資料庫或可靠的工作佇列:以 event_id(由已驗證的 body 重算) 為唯一鍵保存事件,# 已保存過也算成功;保存失敗要拋出例外。完成實作前請維持拋出例外,不要回報成功。def save_event_once(event_id: str, 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 = json.loads(payload) event_id = event_id_from_body(event) # header 的 OenPay-Event-Id 不在簽章範圍內:和重算的值不同就拒收 header_event_id = request.headers.get("OenPay-Event-Id", "") if header_event_id and not hmac.compare_digest(event_id, header_event_id): abort(400) try: save_event_once(event_id, event) # 存好才回 200 except Exception: return "", 503 # 存檔失敗,讓應援重試 return "", 200驗證簽章後仍建議回查
Section titled “驗證簽章後仍建議回查”通知可能重複,也可能順序顛倒。驗證簽章通過代表內容是真的,但不一定是最新狀態。收到付款通知(purpose 是 charge)時,出貨前請回查:
- 收到通知,先回 HTTP 200(有驗證簽章時,驗證通過並保存後再回)。
- 用 body 的
id(27 字元)呼叫GET /transactions/{id}。 - 確認查到的交易屬於你的訂單:比對
orderId與amount。 - 依查到的
status更新訂單。
- 剛付款完成的極短時間內,查到的可能還是舊狀態。查到
charging或initiated時,隔幾秒再查一次。 - 退款與發票通知的
id是事件編號(例如refund#…、invoice#…),不是交易編號;要回查原交易,請用 body 的chargeId。其他通知依purpose處理,欄位見付款通知物件。 token通知沒有 API 可以回查。請確認id是你剛建立的綁卡頁,並核對customId。- 程式範例見快速開始。
同一筆交易的多則通知
Section titled “同一筆交易的多則通知”- 超商代碼、ATM、LINE Pay 會先收到
charging,之後才收到結果,兩則的id相同。 - 通知可能重複送達,也可能順序顛倒。
- 每則通知都帶
OenPay-Event-Id(evt_加 32 個十六進位字元)與OenPay-Delivery-Attempt(第幾次投遞,1 到 3)。同一則通知重送時OenPay-Event-Id不變;同一筆交易先送charging、再送charged是兩個不同的事件。 - 這兩個 header 不在簽章範圍內,不能直接拿來防重複。請用已驗證的 body 依下式重算,和 header 不同就拒收:
evt_+sha256(JSON.stringify([purpose, merchantId, id, status, success]))的前 32 個十六進位字元,缺少的欄位帶null。上方的範例程式已經示範。 - 「標記為已付款」這類動作仍要做到重複執行也安全,並以回查到的狀態決定要做什麼。
- 應援每次只等 10 秒。請先回 200,再把回查、出貨、寄信等工作放到背景處理。
- 不要回 3xx,也不要讓網址需要登入。
- 防火牆有擋外部連線時,請向應援索取付款通知的來源 IP。
沒收到通知時
Section titled “沒收到通知時”請在你的系統排一個定期工作,處理「超過一段時間還沒有結果」的訂單:
- 找出建立超過 15 分鐘、還沒有最終結果的訂單。
- 用
GET /order/{orderId}/transactions查這個訂單的所有交易。 - 有
charged或claimed:補標為已付款。 - 有
charging:繼續等待(超商代碼最多 48 小時、ATM 3 天)。 - 全部是
initiated或failed:視為未付款。
導覽助手暫停服務中。請使用右上角的搜尋,或聯絡客服。
我可以幫你選擇收款方式、回答 API 串接與 CRM 操作問題,並附上文件出處。
