跳到內容

電子發票號碼與狀態

在 ChatGPT 開啟在 Claude 開啟

網域開通「應援代開電子發票」後,API 交易在付款成功後會自動開立發票(建立交易時怎麼帶發票資訊,見單次付款)。你的系統可以用兩種方式取得發票號碼與狀態:

方式 怎麼取得 要先開啟嗎
查詢交易 查詢交易的回應裡,每筆交易都有 invoice 物件 不用
發票通知 發票開立、作廢時,應援 POST 一則通知到你的網址 要請應援開啟

下面三支 API 回傳的每一筆交易都多了 invoice 物件,不用多帶參數:

終端機視窗
curl "https://payment-api.oen.tw/transactions/2HhndgEquCbDzC5OyVxWSGZmd2l" \
-H "Authorization: Bearer $OEN_API_TOKEN"

其他欄位省略,完整欄位見 Transaction 物件。

{
"code": "S0000",
"data": {
"id": "P20260928AB12CD34",
"transactionId": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"amount": 1200,
"status": "charged",
"orderId": "A20260928001",
"paidAt": "2026-09-28T02:41:03.118Z",
"invoice": {
"invoiceNumber": "ZZ00001234",
"invoiceStatus": "issued",
"invoiceDate": "2026-09-28T02:41:30.000Z",
"randomNumber": "0000"
}
},
"message": ""
}
欄位 說明
invoiceNumber 發票號碼。只在 issued、voided 時有值,其他狀態是 null
invoiceStatus 發票狀態,見下表
invoiceDate 開立時間(UTC,ISO 8601)。換算成台北時間就是發票日期。只在 issued、voided 時有值
randomNumber 4 碼隨機碼。只在 issued、voided 時有值
invoiceStatus 意思 建議做法
issuing 開立中,還沒拿到號碼 稍後再查
issued 已開立,發票有效。作廢處理中、作廢失敗的發票也是這個狀態 記下號碼
failed 開立失敗 聯絡應援客服
voided 已作廢。invoiceNumber 是 null 時,表示發票還沒開出就隨全額退款取消 依你的流程更新訂單
unknown 其他狀態 稍後再查,或聯絡應援
  • invoice 一定會出現。交易沒有發票時是 null:網域沒開通電子發票、交易還沒付款,或發票暫時讀不到。
  • 剛付款完成時查詢,invoice 可能還是 null 或 issuing,稍後再查即可。
  • 退款開立折讓不會改變發票狀態,部分退款後仍是 issued。
  • 發票作廢後由應援重新開立、並對應到原交易時,查到的是新的那張發票。
  • 回應不含任何買受人資料(統編、姓名、地址、電話、Email、載具、捐贈碼)。
  1. 請聯絡應援,為你的網域開啟「Webhook 新版事件」。測試環境與正式環境分別開啟。
  2. 通知送到你在 CRM 設定的「交易資料回傳網址位置」,和付款通知是同一個網址,不用另外設定。
  3. 這個開關也會開啟 refund、subscription_cancelled、subscription_retry 三種通知。用不到的請依 purpose 略過,不要回錯誤。

開啟之前發生的開立、作廢,開啟後不會補送。

purpose 什麼時候送
invoice_issued 發票開立成功、拿到發票號碼之後。應援在後台重試開立成功時也會送
invoice_voided 發票作廢成功之後。作廢由應援客服處理,見電子發票

同一張發票,每一種通知只送一次。發票作廢後由應援重新開立、並對應到原交易時,舊發票送 invoice_voided,新發票送 invoice_issued,兩則的 chargeId 相同、id 不同。

不會送通知的情況:

  • 不是 API 交易的發票,例如 Embed、商店訂單、POS 的發票。
  • 退款開立的折讓。
  • 發票還沒開出就隨全額退款取消(沒有發票號碼)。查詢交易會回 voided,invoiceNumber 是 null。
  • 開立失敗的發票。查詢交易會回 failed。
POST /webhooks/oen HTTP/1.1
Host: shop.example.com
Content-Type: application/json
{
"purpose": "invoice_issued",
"id": "invoice#2HhnqT4cWz0mR8bLxN3vYs6KdPa#issued",
"transactionId": "invoice#2HhnqT4cWz0mR8bLxN3vYs6KdPa#issued",
"merchantId": "ming",
"chargeId": "2HhndgEquCbDzC5OyVxWSGZmd2l",
"orderId": "A20260928001",
"invoiceNumber": "ZZ00001234",
"invoiceStatus": "issued",
"invoiceDate": "2026-09-28T02:41:30.000Z",
"randomNumber": "0000",
"invoiceAmount": 1200,
"createdAt": "2026-09-28T02:41:31.204Z",
"message": ""
}

invoice_voided 的格式相同:purpose 是 invoice_voided、invoiceStatus 是 voided、id 結尾是 #voided,號碼、日期、隨機碼是被作廢的那張發票。

幾個容易弄錯的欄位(完整欄位見付款通知內容):

  • id:這則通知的事件編號,同一張發票的同一種通知固定不變,可以用來判斷重複。
  • chargeId:發票所屬交易的內部 id,和 charge 通知的 id 相同。
  • transactionId:和 id 相同,不是交易編號。沿用 charge 通知的程式時請特別注意。
  • invoiceAmount:發票金額(含稅),是發票品項的加總;品項資料不完整時是 null。
  • createdAt:這則通知建立的時間,不是交易建立時間。
  1. 先回 HTTP 2xx,再到背景處理。應援每次最多等 10 秒。
  2. 用 id 判斷是否處理過,處理過就略過。
  3. 有產生簽章密鑰時,先驗證 OenPay-Signature(見確認通知的真假);沒有密鑰時通知沒有簽章。不論哪一種,都可以用 chargeId 呼叫 GET /transactions/{id},比對回應裡 invoice 的 invoiceNumber 與 invoiceStatus。
  4. 依 invoiceStatus 更新訂單的發票資料。
  • 送出規則和付款通知相同:最多送 3 次,間隔約 2 秒、4 秒;不跟隨轉址;3 次都失敗就不會再送。見付款通知(webhook)。
  • 重新開立時,invoice_voided 與 invoice_issued 可能順序顛倒送達。請以 invoiceStatus 與 invoiceNumber 判斷,不要依收到的順序。
  • 發票狀態更新後如果系統中斷,這則通知不會補送。請定期用用訂單編號查詢或查詢交易列表對帳,補上還沒有發票號碼的訂單。
app.post("/webhooks/oen", express.json(), (req, res) => {
res.sendStatus(200); // 先回 200
const event = req.body;
if (event.purpose === "invoice_issued" || event.purpose === "invoice_voided") {
handleInvoice(event).catch((err) => console.error("OEN invoice webhook", err));
}
// charge 等其他 purpose 另外處理;不認得的 purpose 一律略過
});
async function handleInvoice(event) {
if (await alreadyProcessed(event.id)) return; // 用 id 去重
// 不相信通知內容,用 chargeId 回查
const res = await fetch(`https://payment-api.oen.tw/transactions/${encodeURIComponent(event.chargeId)}`, {
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 invoice = result.data.invoice;
if (!invoice || invoice.invoiceNumber !== event.invoiceNumber) return; // 已被重新開立等情況,以回查為準
await saveInvoice(result.data.orderId, invoice); // 同一張重複寫入也要安全
await markProcessed(event.id);
}