Workat 電子發票 API
透過 API 開立財政部電子發票(MIG 4.1 平台存證),由系統負責取號、產生 XML、交給 Turnkey 上傳,並回報平台的處理結果。
API 列表
開立一張電子發票(F0401),並交給 Turnkey 上傳財政部。
以發票號碼查詢發票內容與上傳狀態。
作廢一張已開立的發票(F0501),例如開立錯誤或當期內銷貨退回。
註銷一張已存證的發票(F0701),並以同一個號碼重新開立新的內容(F0401)。
取得電子發票證明聯(5.7 公分感熱紙格式)的 HTML,以及一維條碼、兩個 QR Code 的內容。
開立折讓證明單(G0401),用於不能作廢原發票的銷貨退回或折讓。
以折讓證明單號碼查詢內容與上傳狀態。
作廢一張折讓證明單(G0501)。
取得營業人銷貨退回、進貨退出或折讓證明單的 HTML(5.7 公分或 A4),供買方簽收。
連線位址
https://invoice.workat.studio/api/v1
- 一律使用 HTTPS。
- 請求與回應皆為 JSON,編碼為 UTF-8。
- 請求時帶上
Content-Type: application/json與Accept: application/json。 - 所有日期與時間皆為台灣時間(UTC+8),這是財政部 MIG 的規定。
驗證方式
每個請求都必須以 HMAC-SHA256 簽章驗證身分。系統管理者會發給你兩樣東西:
| 項目 | 例 | 說明 |
|---|---|---|
| Key ID(公開) | wk_k3j9x2m7q1p8r4t6v0w5 | 每個請求放在 X-Api-Key Header |
| Secret(私密) | 48 碼英數字 | 只用來計算簽章,永遠不要放進請求裡。只會在建立時顯示一次 |
- 一把金鑰只屬於一間公司:開立的發票一律以這間公司為賣方,請求中不需要、也無法指定賣方統編;也查不到其他公司的發票。
- 每把金鑰都綁定允許的來源 IP(建立金鑰時提供你伺服器的固定對外 IP)。從其他 IP 呼叫一律回應
403。 - 每把金鑰每分鐘最多 120 個請求,超過回應
429。 - 每一次呼叫(包含失敗的)都會留下紀錄。
每個請求必須帶的 Header
| Header | 內容 |
|---|---|
X-Api-Key | Key ID |
X-Timestamp | 目前的 Unix 時間(秒)。與伺服器時間相差超過 300 秒即拒絕,請確認伺服器有校時(NTP) |
X-Nonce | 每個請求都不同的亂數,16–64 碼英數字(可含 _、-)。用過就不能再用 |
X-Signature | 簽章(64 碼小寫十六進位),算法見下方 |
Content-Type | application/json |
Accept | application/json |
簽章算法
把以下 5 個值用換行字元 \n 串起來,以 Secret 為金鑰計算 HMAC-SHA256,輸出小寫十六進位:
StringToSign = X-Timestamp + "\n"
+ X-Nonce + "\n"
+ HTTP 方法(大寫,例 POST) + "\n"
+ 路徑(含查詢字串,例 /api/v1/invoices) + "\n"
+ SHA256(請求內容)(小寫十六進位;GET 沒有內容時為空字串的 SHA256)
X-Signature = HMAC-SHA256(Secret, StringToSign) → 小寫十六進位
- 「請求內容」是實際送出的原始位元組。請先把 JSON 序列化成字串,用同一個字串計算雜湊並送出,不要讓 HTTP 函式庫重新序列化。
- 空字串的 SHA256 是
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855。 - 這樣設計的效果:Secret 不在網路上傳;內容、方法或路徑被竄改時簽章對不上;同一個請求不能重送。
測試向量
寫好簽章程式後,先用這組固定值驗證結果是否一致:
| Secret | 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL |
|---|---|
| X-Timestamp | 1791119073 |
| X-Nonce | 3f9c1e7a2b8d4c6e9f0a1b2c3d4e5f60 |
| 方法、路徑 | POST、/api/v1/invoices |
| 請求內容 | {"items":[{"description":"綠茶","quantity":1,"unit_price":25}]}(UTF-8,無空白) |
| SHA256(內容) | eaf495d6aa88357a628b3d8ef0dfea11d00c5dd50d593af06568dc0dfaefad1c |
| X-Signature | bad751f03cef7b620b58a34f6acccec89ba25d8390dc617033c40a538b41d3e3 |
範例:PHP
function einvoiceRequest(string $method, string $path, ?array $data): array
{
$keyId = getenv('EINVOICE_KEY_ID');
$secret = getenv('EINVOICE_SECRET');
$body = $data === null ? '' : json_encode($data, JSON_UNESCAPED_UNICODE);
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$toSign = implode("\n", [$timestamp, $nonce, strtoupper($method), $path, hash('sha256', $body)]);
$ch = curl_init('https://invoice.workat.studio' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => strtoupper($method),
CURLOPT_POSTFIELDS => $body === '' ? null : $body, // 送出的就是簽章用的同一個字串
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
"X-Api-Key: {$keyId}",
"X-Timestamp: {$timestamp}",
"X-Nonce: {$nonce}",
'X-Signature: ' . hash_hmac('sha256', $toSign, $secret),
],
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return [$status, json_decode($response, true)];
}
[$status, $result] = einvoiceRequest('POST', '/api/v1/invoices', [
'external_id' => 'ORDER-20261004-0001',
'items' => [['description' => '綠茶', 'quantity' => 1, 'unit_price' => 25]],
]);
範例:bash(curl + openssl)
KEY_ID='wk_…'; SECRET='…'
BODY='{"items":[{"description":"綠茶","quantity":1,"unit_price":25}]}'
TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
SIG=$(printf '%s\n%s\n%s\n%s\n%s' "$TS" "$NONCE" POST /api/v1/invoices "$BODY_HASH" \
| openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST https://invoice.workat.studio/api/v1/invoices \
-H "Content-Type: application/json" -H "Accept: application/json" \
-H "X-Api-Key: $KEY_ID" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Signature: $SIG" \
--data-binary "$BODY"
回應格式
成功時,資料放在 data 底下:
{
"data": {
"invoice_number": "KZ10000000",
"status": "sent",
...
}
}
錯誤回應
| HTTP 狀態碼 | 意義 | 處理方式 |
|---|---|---|
401 | 驗證失敗,原因在 error(見下表) | 依原因修正 |
403 | 來源 IP 不在金鑰的允許清單(ip_not_allowed) | 確認伺服器的對外 IP,請系統管理者更新清單 |
429 | 請求過於頻繁(rate_limited) | 稍後再試 |
404 | 找不到資料(例如查詢不存在、或不屬於你公司的發票) | 確認發票號碼 |
409 | 沒有可用的字軌(當期字軌尚未匯入,或號碼已用完) | 聯絡系統管理者匯入字軌後再重送 |
422 | 輸入資料不符合規則 | 依 errors 中各欄位的訊息修正後重送 |
500 | 系統錯誤 | 可用相同的 external_id 安全重送(見下方「防止重複開立」) |
驗證失敗時的回應格式與 error 代碼:
{
"message": "簽章不正確",
"error": "invalid_signature"
}
| error | 原因 |
|---|---|
missing_headers | 缺少 X-Api-Key、X-Timestamp、X-Nonce、X-Signature 其中之一 |
invalid_key | Key ID 不存在、金鑰已停用,或公司已停用 |
ip_not_allowed | 來源 IP 不在允許清單(HTTP 403) |
timestamp_out_of_range | 時間戳格式錯誤,或與伺服器時間相差太多 |
invalid_nonce | Nonce 格式錯誤 |
invalid_signature | 簽章不符。常見原因:Secret 錯誤、送出的內容與簽章時不同、路徑未含查詢字串 |
nonce_reused | Nonce 已用過(請求被重送) |
422 的回應格式:
{
"message": "買方統一編號檢查碼錯誤",
"errors": {
"buyer_ban": ["買方統一編號檢查碼錯誤"]
}
}
防止重複開立
開立發票時建議帶上 external_id(例如你系統裡的訂單編號)。同一間公司用相同的 external_id 再次呼叫時,不會開出第二張發票,而是回傳第一次開立的那張(HTTP 200,第一次為 201)。
因此遇到逾時、網路中斷或 500 時,可以放心用同一個 external_id 重送。
發票狀態
發票開立後,會依序經過以下狀態。status 會隨著上傳進度更新,可透過查詢發票取得最新狀態。
| status | 意義 |
|---|---|
| issued | 已開立(已取號並存檔),尚未交給 Turnkey。通常幾秒內就會變成 sent;若 Turnkey 暫時無法寫入,系統每分鐘自動重試。 |
| sent | 已交給 Turnkey,等待財政部平台回覆處理結果。 |
| uploaded | 財政部平台已存證成功,流程完成。 |
| failed | Turnkey 或財政部平台回報錯誤,原因在 upload_error。需要由系統管理者處理。 |
| cancelling | 註銷重開進行中:內容已改為新的,等財政部確認註銷後,系統會自動以同一個號碼重新開立,再回到 sent → uploaded。 |
issued 時就已經合法開立、號碼已確定,可以先交付給消費者;sent → uploaded 只是上傳財政部的進度。發票被作廢後,status 不會改變(仍表示開立的上傳進度),作廢資訊與作廢的上傳進度放在 void 欄位;未作廢時 void 為 null。
欄位長度與格式
各欄位的長度上限依財政部 MIG 4.1 規格,詳見各 API 頁面。金額一律為新台幣。