台灣電子發票 API 串接 Shopify Plus 教學:完整實作指南

Key Takeaways
- Shopify 不支援台灣發票,必須靠中介服務呼叫發票 API
- 用 `orders/paid` webhook 觸發,先回 200 再進佇列處理
- 一定要做 idempotency,Shopify 48 小時內會重試 19 次
- 同期全額退款走作廢,跨期或部分退款走折讓
- 上線前務必驗證運費、折扣與含稅金額完全對得上
要在 Shopify Plus 上開立台灣電子發票,標準做法是:用 orders/paid webhook 觸發中介服務,把訂單轉成發票欄位(載具、統編、捐贈碼),呼叫綠界或藍新的 B2C 電子發票 API 開立,再把發票號碼寫回訂單 metafield。
這篇教學會把整條鏈路拆開講:前置準備、三種架構的取捨、webhook 驗證、AES 加密請求、回寫、退款作廢,以及我們在跨境專案上常踩到的錯。程式碼以 Node.js 18+ 為例,API 以綠界科技(ECPay)B2C 電子發票 v3 為範例,藍新(NewebPay)、關貿(Tradevan)的欄位邏輯大同小異。
為什麼 Shopify Plus 在台灣一定要外掛發票流程
Shopify 本身沒有台灣統一發票的概念。它能算 5% 營業稅(依財政部規定,台灣營業稅率為 5%),能印 packing slip,但它不會產生 10 碼發票號碼、不會處理手機條碼載具、也不會把資料上傳到財政部電子發票整合服務平台。
而台灣的法遵要求是硬性的:依財政部電子發票整合服務平台的作業規定,B2C 電子發票須於開立後 48 小時內上傳平台,B2B 則為 7 日內。發票字軌號碼由國稅局以每兩個月為一期配號,用完要申請、跨期要換軌。這些都發生在 Shopify 之外,所以你需要一層中介服務(middleware)把兩邊接起來。
對總部在香港、新加坡或倫敦、但在台灣有銷售實體的品牌來說,這層中介服務還有第二個價值:它是唯一同時看得到「Shopify 訂單資料」與「台灣稅務資料」的地方。財報對帳、跨境退稅、月結核帳都靠它。
串接前要備齊的東西
稅務與帳號面
- 台灣公司統一編號,且已在財政部電子發票整合服務平台完成營業人身分註冊。
- 已向國稅局申請並取得當期發票字軌號碼(字軌區間需在加值服務商後台設定好)。
- 選定加值服務商(Turnkey 自建、或綠界/藍新/關貿等 API 服務商)。自建 Turnkey 需要自己處理 MIG 3.2.1 XML 與訊息交換,成本高;中小型電商用 API 服務商比較務實。
- 服務商後台的
MerchantID、HashKey、HashIV(綠界 v3 電子發票 API 的加解密金鑰與金流用的不同,別拿錯)。
技術面
- Shopify Plus store,且有建立 custom app 的權限。
- Admin API scopes:
read_orders、write_orders、read_products、write_metafields(若用 Shopify Functions 或 Flow 另計)。 - 一個可公開存取的 HTTPS endpoint(Cloudflare Workers、AWS Lambda + API Gateway、或任何 Node 服務)。
- 一個具備 idempotency 的儲存層(Redis、DynamoDB、Cloudflare KV 都可),避免重複開立。
測試環境
綠界提供 stage 環境:https://einvoice-stage.ecpay.com.tw,正式為 https://einvoice.ecpay.com.tw。Shopify 這端建議開一個 development store 或在 Plus 的 staging environment 上測,避免把測試發票開進正式字軌。
Ready to Transform Your Ecommerce Operations?
Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.
三種串接架構怎麼選
A. 現成 App(Shopify App Store 的台灣發票外掛)
適合單一市場、訂單結構單純的品牌。優點是幾小時上線;缺點是訂製彈性低——分期出貨、部分退款、預購訂金、B2B 統編驗證這些情境常常卡住。
B. 自建 Middleware(本文主軸)
一個獨立的小服務接 Shopify webhook,呼叫發票 API,回寫結果。優點:完全可控、可擴充到多市場(台灣開電子發票、香港出 invoice PDF、新加坡算 GST)。缺點:你要自己負責重試、監控、對帳。
C. Shopify Flow + HTTP Request
Plus 方案內建的 Flow 可以用 Order paid 觸發 Send HTTP request。適合做低量補救或通知,不建議當主線路——Flow 沒有可靠的重試語意,也很難做欄位轉換與錯誤分類。
實務上我們多半是 B 為主、C 當作人工補開的後備通道。
步驟一:建立 webhook 並驗證 HMAC
先用 Admin GraphQL 註冊 webhook。建議訂閱 ORDERS_PAID(開立)與 REFUNDS_CREATE(折讓/作廢)。
1mutation {2 webhookSubscriptionCreate(3 topic: ORDERS_PAID4 webhookSubscription: {5 callbackUrl: "https://invoice.example.com/hooks/orders-paid"6 format: JSON7 }8 ) {9 webhookSubscription { id }10 userErrors { field message }11 }12}
接收端務必驗證 HMAC,否則任何人都能偽造訂單觸發開票:
1import crypto from 'node:crypto';2import express from 'express';34const app = express();5app.use('/hooks', express.raw({ type: 'application/json' }));67function verifyShopifyHmac(req) {8 const hmac = req.get('X-Shopify-Hmac-Sha256') || '';9 const digest = crypto10 .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)11 .update(req.body) // 必須是 raw buffer,不能是 parsed object12 .digest('base64');13 return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(digest));14}1516app.post('/hooks/orders-paid', async (req, res) => {17 if (!verifyShopifyHmac(req)) return res.status(401).send('bad hmac');18 res.status(200).send('ok'); // 先回 200,避免 Shopify 判定逾時19 await enqueue('issue-invoice', JSON.parse(req.body.toString()));20});
兩個關鍵:
- 先回 200 再處理。 依 shopify.dev 的 webhook 文件,接收端必須在 5 秒內回應,失敗後 Shopify 會在 48 小時內重試 19 次。開票 API 偶爾會慢,不要讓它卡住 HTTP 回應。
- 走佇列。 重試 19 次意味著同一張訂單可能被送 19 遍,idempotency key 用
X-Shopify-Webhook-Id或order.id都可以,但要在寫入發票之前就卡住。
1async function enqueue(job, payload) {2 const key = `inv:${payload.id}`;3 const first = await kv.set(key, 'pending', { nx: true, ex: 86400 });4 if (!first) return; // 已處理過,直接略過5 await queue.send({ job, payload });6}
Ready to Transform Your Ecommerce Operations?
Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.
步驟二:把 Shopify 訂單對應成發票欄位
這是最容易出錯的一段,因為台灣發票的資料不存在 Shopify 的標準欄位裡。常見做法是在結帳頁用 Checkout Extensibility(Plus 才有的 checkout UI extension)收集:
- 發票類型:個人(載具)/捐贈/公司(統編)
- 手機條碼(
/開頭共 8 碼)或自然人憑證(2 碼英文 + 14 碼數字) - 愛心碼(3–7 碼數字)
- 買受人統一編號(8 碼)與發票抬頭
收集後寫入 order 的 note_attributes 或 metafield。綠界的載具類別代碼為:1 會員載具、2 自然人憑證、3 手機條碼(舊版 API 另有 EJ0113、CQ0001、3J0002 等格式代碼,以服務商文件為準)。
轉換邏輯大致如下:
1function toInvoicePayload(order) {2 const attrs = Object.fromEntries(3 (order.note_attributes || []).map(a => [a.name, a.value])4 );56 const items = order.line_items.map(li => ({7 ItemName: li.title.slice(0, 100),8 ItemCount: li.quantity,9 ItemWord: '件',10 ItemPrice: Number(li.price), // 含稅單價11 ItemTaxType: '1', // 1=應稅 2=零稅率 3=免稅12 ItemAmount: Number(li.price) * li.quantity13 }));1415 // 運費也要開立,否則發票總額對不上16 const shipping = Number(order.total_shipping_price_set.shop_money.amount);17 if (shipping > 0) {18 items.push({ ItemName: '運費', ItemCount: 1, ItemWord: '式',19 ItemPrice: shipping, ItemTaxType: '1', ItemAmount: shipping });20 }2122 const salesAmount = items.reduce((s, i) => s + i.ItemAmount, 0);2324 return {25 MerchantID: process.env.ECPAY_MERCHANT_ID,26 RelateNumber: order.name.replace('#', ''), // 自訂關聯編號,需唯一27 CustomerName: attrs['invoice_title'] || `${order.customer?.last_name ?? ''}${order.customer?.first_name ?? ''}`,28 CustomerEmail: order.email,29 CustomerIdentifier: attrs['tax_id'] || '', // 統編,有填則為 B2B30 Print: attrs['tax_id'] ? '1' : '0',31 Donation: attrs['love_code'] ? '1' : '0',32 LoveCode: attrs['love_code'] || '',33 CarrierType: attrs['carrier_type'] || '',34 CarrierNum: attrs['carrier_num'] || '',35 TaxType: '1',36 SalesAmount: Math.round(salesAmount),37 Items: items,38 InvType: '07'39 };40}
三個一定要處理的邊界
折扣。 Shopify 的 discount 可能落在 order 層級或 line item 層級。發票的 SalesAmount 必須等於實收金額,所以要用 line_items[].discount_allocations 把折扣攤回每個品項,不能只在總額扣。
含稅/未稅。 台灣 B2C 慣例是含稅定價。若 Shopify 設定為「價格不含稅」,ItemPrice 要先換算回含稅價再送,否則發票金額會短少 5%。
統編 + 載具互斥。 有統編(B2B)時不能同時帶載具,也不能捐贈;綠界會直接回錯誤碼。在前端就擋掉比在後端處理退單容易得多。
步驟三:呼叫綠界 B2C 電子發票 v3 API
綠界 v3 的請求格式是:外層 JSON 帶 MerchantID、RqHeader.Timestamp 與 Data,Data 是用 HashKey/HashIV 做 AES-128-CBC 加密後、再 URL-encode 的字串。
1import crypto from 'node:crypto';23const KEY = process.env.ECPAY_HASH_KEY; // 16 bytes4const IV = process.env.ECPAY_HASH_IV; // 16 bytes56function encrypt(obj) {7 const plain = encodeURIComponent(JSON.stringify(obj));8 const cipher = crypto.createCipheriv('aes-128-cbc', KEY, IV);9 return Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])10 .toString('base64');11}1213function decrypt(b64) {14 const d = crypto.createDecipheriv('aes-128-cbc', KEY, IV);15 const plain = Buffer.concat([d.update(Buffer.from(b64, 'base64')), d.final()]);16 return JSON.parse(decodeURIComponent(plain.toString('utf8')));17}1819async function issueInvoice(payload) {20 const base = process.env.ECPAY_BASE; // stage 或 production21 const body = {22 MerchantID: process.env.ECPAY_MERCHANT_ID,23 RqHeader: { Timestamp: Math.floor(Date.now() / 1000) },24 Data: encrypt(payload)25 };2627 const res = await fetch(`${base}/B2CInvoice/Issue`, {28 method: 'POST',29 headers: { 'Content-Type': 'application/json' },30 body: JSON.stringify(body)31 });3233 const json = await res.json();34 const data = decrypt(json.Data);35 if (data.RtnCode !== 1) {36 throw new InvoiceError(data.RtnCode, data.RtnMsg, payload.RelateNumber);37 }38 return { invoiceNo: data.InvoiceNo, invoiceDate: data.InvoiceDate,39 randomNumber: data.RandomNumber };40}
預期成功回應(解密後)大致長這樣:
1{2 "RtnCode": 1,3 "RtnMsg": "開立發票成功",4 "InvoiceNo": "AB12345678",5 "InvoiceDate": "2026-01-15 14:02:11",6 "RandomNumber": "4716"7}
Timestamp 與綠界伺服器時差過大會被拒絕,容器時區請設 UTC 並用 NTP 校時。
Ready to Transform Your Ecommerce Operations?
Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.
步驟四:把發票號碼回寫到 Shopify
發票號碼要寫回訂單,客服、對帳、退款才查得到。用 metafield 最乾淨:
1mutation SetInvoice($id: ID!, $no: String!, $date: String!) {2 metafieldsSet(metafields: [3 { ownerId: $id, namespace: "tw_invoice", key: "number",4 type: "single_line_text_field", value: $no },5 { ownerId: $id, namespace: "tw_invoice", key: "issued_at",6 type: "single_line_text_field", value: $date }7 ]) {8 metafields { id key value }9 userErrors { field message }10 }11}
再加一個 order tag(例如 invoice-issued)方便在 Shopify 後台篩選。開票失敗時改打 invoice-failed tag,營運同事一眼就看得到哪幾張要補。
速率限制方面,依 shopify.dev 的 Admin API rate limit 文件,Shopify Plus 的 GraphQL Admin API 配額高於標準方案(Plus 為標準方案的 10 倍)。即便如此,大量回補時仍建議讀取回應裡的 extensions.cost.throttleStatus,依剩餘點數動態調整併發。
步驟五:退款、作廢與折讓怎麼處理
台灣的規則很直接:
- 同期(當月同一發票期別)且全額退款 → 走「作廢」(
/B2CInvoice/Invalid)。 - 跨期,或部分退款 → 走「折讓」(
/B2CInvoice/AllowanceByCollegiate或/B2CInvoice/Allowance)。
用 REFUNDS_CREATE webhook 判斷:
1async function onRefund(refund, order) {2 const inv = await getInvoiceMeta(order.id);3 if (!inv) return flagForManualReview(order, 'no invoice found');45 const refundAmount = Number(refund.transactions?.[0]?.amount ?? 0);6 const isFull = refundAmount >= Number(order.total_price);7 const samePeriod = inSameInvoicePeriod(inv.issuedAt, new Date());89 if (isFull && samePeriod) {10 return callEcpay('/B2CInvoice/Invalid', {11 MerchantID: process.env.ECPAY_MERCHANT_ID,12 InvoiceNo: inv.number,13 InvoiceDate: inv.issuedAt.slice(0, 10),14 Reason: '訂單取消'15 });16 }17 return callEcpay('/B2CInvoice/Allowance', {18 MerchantID: process.env.ECPAY_MERCHANT_ID,19 InvoiceNo: inv.number,20 InvoiceDate: inv.issuedAt.slice(0, 10),21 AllowanceNotify: 'E',22 NotifyMail: order.email,23 AllowanceAmount: Math.round(refundAmount),24 Items: buildAllowanceItems(refund)25 });26}
發票期別以「單月、雙月為一期」計算(1–2 月、3–4 月……),inSameInvoicePeriod 就是比對兩個日期是否落在同一組雙月期間。
折讓的品項金額必須小於等於原發票對應品項金額,而且同一張發票可以多次折讓但累計不得超過原額——部分退款多次發生時,要在自己的 DB 累計已折讓金額。
Ready to Transform Your Ecommerce Operations?
Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.
常見錯誤與排錯清單
CheckMacValue/解密失敗:九成是 HashKey/HashIV 拿到金流那組而非電子發票那組,或是忘了在加密前做 encodeURIComponent。先用服務商提供的測試值跑一次 encrypt/decrypt 來回確認。
RtnMsg 提示字軌號碼不足:當期字軌用完。這在促銷檔期特別容易發生,建議在中介服務加一個每日檢查剩餘號碼數的排程,低於門檻就發 Slack 告警。
發票金額與訂單金額差 1 元:含稅價四捨五入造成。把「以含稅單價 × 數量加總」當唯一真相,不要分別算稅額再加總。
同一訂單開出兩張發票:webhook 重試 + 沒有 idempotency。補救方式是作廢其中一張,但預防遠比補救便宜。
載具格式錯誤:手機條碼必須是 / 加 7 碼英數大寫,使用者常把全形斜線或小寫貼上。在 checkout extension 做正規化(toUpperCase()、全形轉半形)。
客戶沒收到發票通知:綠界的通知信靠 CustomerEmail,而 Shopify 的 guest checkout 可能沒有 customer 物件。永遠從 order.email 取值,而非 order.customer.email。
跨境團隊怎麼管這條流程
我們曾替一個總部設在香港、同時經營台灣與東南亞站點的零售集團做過類似的架構:Shopify Plus 用多店(multi-store)切市場,中介服務只有一套,以 shop domain 判斷要走台灣電子發票、還是只產出 PDF invoice。好處是稅務邏輯集中、程式碼一份;代價是這套服務變成單點,所以監控與告警必須做到跟金流同級。
跨時區團隊還有一個現實面:台灣國稅局的字軌申請、商戶註冊只能在台北上班時間處理,而開票失敗常發生在深夜促銷。合理的分工是由在地團隊負責稅務與服務商溝通,工程維運則由香港/新加坡/台北輪班覆蓋,讓營運時間拉到 14 小時以上。
AI 在這裡能幫上的其實是分類而非決策:把 RtnCode 與 RtnMsg 丟給 LLM 歸類成「可自動重試」「需人工補資料」「需稅務介入」三類,再路由到不同通知頻道,可以省掉大量人工判讀。但真正的開票、作廢動作,請保持確定性程式碼,不要交給模型。
Ready to Transform Your Ecommerce Operations?
Branch8 specializes in ecommerce platform implementation and AI-powered automation solutions. Contact us today to discuss your ecommerce automation strategy.
上線前的驗收清單
- stage 環境開立 B2C(手機條碼)、B2C(捐贈)、B2B(統編)各一張,並在服務商後台確認資料正確。
- 測試全額退款(作廢)與部分退款(折讓)各一次。
- 故意關閉中介服務,確認 Shopify webhook 重試後不會重複開票。
- 確認含運費、含折扣、含禮品卡的訂單金額與發票總額完全一致。
- 確認失敗訂單會被打上 tag,且有人會看到告警。
- 確認正式環境字軌區間已設定,且有剩餘號碼監控。
- 跑一次月結對帳:Shopify 訂單報表 vs 發票明細,筆數與金額需逐筆對得上。
把這七項跑完,再切正式流量。發票是稅務文件,補救成本遠高於金流,寧可多測一輪。
Branch8 在香港、台北、新加坡與澳洲都有交付團隊,長期替跨境品牌處理 Shopify Plus 的在地化串接——台灣電子發票、本地金流物流、多店架構與資料對帳。如果你正在評估自建 middleware 或現成 App 的取捨,歡迎與我們談談你的訂單結構與稅務情境。
Sources
FAQ
不行。Shopify 原生只能計算 5% 營業稅並列印訂單文件,無法產生台灣統一發票號碼、處理載具或上傳財政部電子發票整合服務平台。必須透過外掛 App 或自建中介服務串接綠界、藍新、關貿等加值服務商的 API。
About the Author
Matt Li
Co-Founder & CEO, Branch8 & Second Talent
Matt Li is Co-Founder and CEO of Branch8, a Y Combinator-backed (S15) Adobe Solution Partner and e-commerce consultancy headquartered in Hong Kong, and Co-Founder of Second Talent, a global tech hiring platform ranked #1 in Global Hiring on G2. With 12 years of experience in e-commerce strategy, platform implementation, and digital operations, he has led delivery of Adobe Commerce Cloud projects for enterprise clients including Chow Sang Sang, HomePlus (HKBN), Maxim's, Hong Kong International Airport, Hotai/Toyota, and Evisu. Prior to founding Branch8, Matt served as Vice President of Mid-Market Enterprises at HSBC. He serves as Vice Chairman of the Hong Kong E-Commerce Business Association (HKEBA). A self-taught software engineer, Matt graduated from the University of Toronto with a Bachelor of Commerce in Finance and Economics.