如何利用 OCR API 與 Webhook 打造應付帳款自動化管道
了解如何建立一個可靠的應付帳款自動化管道。使用非同步 OCR API 上傳發票,並透過安全的 Webhook 接收結構化資料。
處理發票是應付帳款作業中的典型瓶頸。人工輸入資料不僅耗時,還容易出錯。即使導入了 OCR API,若採用輪詢(polling)方式來檢查任務是否完成,效率依然不彰。這種做法會增加系統的複雜度、延遲和不必要的網路流量。一個真正自動化的應付帳款流程,不該是反覆詢問「好了沒?」的等待,而應該是事件驅動(event-driven)的,在資料準備好的那一刻立即回應。

這正是 space-ocr 非同步處理的核心理念。您不必在發出請求後,還得佔用連線空等回應。您可以一次上傳整批發票,然後立即回頭處理其他工作。API 會回傳一組任務識別碼,確認檔案已收到。接著,我們的引擎會處理每一張圖片。當一張發票成功讀取後,系統會直接向您應用程式的端點發送通知。這個通知是透過 Webhook——一個簡單、可靠的 HTTP POST 請求——來完成,請求中就包含了結構化的資料。
整個流程非常直接:您只需發起一次 POST /upload 呼叫,附上您的發票圖片。該端點接受 multipart/form-data,單次請求最多 20 個 files,單一檔案 20MB,整個請求 28MB,超過會回傳 413。預設為非同步,因此回應是一個 jobs[] 陣列,每個檔案一筆,各自帶有 jobId 與 status: "pending"。稍後,您的伺服器會透過 Webhook 收到一個 ocr.completed 事件。所有事件共用同一個 envelope(event / deliveryId / occurredAt / apiVersion / data),而這裡的 data.result 是與 GET /jobs/{jobId} 完全相同的 v2 結構 { values, cells, review, image }:values 就是發票資料本身,從供應商名稱(如「弥生サンブル」)到每一筆品項;cells[path] 保存該值在頁面上的 box 與 quad;review.flagged 則是需要人工確認的路徑清單。為了確保可靠性,每次上傳都可以在請求標頭中加入 Idempotency-Key。若您因網路問題需要重試請求,發送相同的金鑰能保證您不會重複建立處理任務。
欄位本身只在發票要進入的表格上宣告一次(POST /create 的 columns)。宣告不會傳給模型,所以並不會讓擷取更準確;它增加的是複核訊號與一層確定性的解析結果:
invoice_no—required搭配pattern:號碼缺漏會標記missing,格式不符則是pattern_mismatch。invoice_date—type: "date":解析後的值放進data.normalized,若印出的日期無法被讀成日期就會出現type_mismatch。total—type: "number"加上min: 0:負數或讀不出數值的金額會以out_of_range或type_mismatch浮現。supplier— 供應商是固定名單時用enum;否則用near/not_near指定名稱旁應該(或不應該)印出的詞彙,對應near_mismatch/near_conflict。
宣告不會改寫 values。解析後的值另外放在 data.normalized,原因則在 review.flagged[].reasons,永遠是依排序排列的陣列,第 0 筆為主要原因。
const express = require('express');
const crypto = require('crypto');
const app = express();
const webhookSecret = process.env.SPACE_OCR_WEBHOOK_SECRET;
// The signature covers the raw bytes, so keep a copy before the JSON parser runs.
// Raise the parser limit too: an ocr.completed payload carries a cell per field.
const rawBodySaver = (req, res, buf, encoding) => {
if (buf && buf.length) {
req.rawBody = buf.toString(encoding || 'utf8');
}
};
app.use(express.json({ verify: rawBodySaver, limit: '5mb' }));
// X-Spaceocr-Signature: t=<unix_ms>,v1=<hex>
// Canonical string: `${t}.${rawBody}` — HMAC-SHA256 with your webhook secret.
function verifySignature(secret, header, rawBody) {
const m = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header || '');
if (!m) return false;
const [, t, v1] = m;
// Replay guard: reject a timestamp that drifts more than 5 minutes.
if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'),
'hex',
);
const received = Buffer.from(v1, 'hex');
return (
expected.length === received.length &&
crypto.timingSafeEqual(expected, received)
);
}
app.post('/webhook', (req, res) => {
const signature = req.get('X-Spaceocr-Signature');
if (!verifySignature(webhookSecret, signature, req.rawBody)) {
return res.status(400).send('Invalid signature');
}
// A redelivery reuses the same id, so it doubles as the de-duplication key.
const deliveryId = req.get('X-Spaceocr-Delivery');
const event = req.body; // event / deliveryId / occurredAt / apiVersion / data
switch (event.event) {
case 'ocr.completed': {
// result = { values, cells, review, image } — the same v2 shape as GET /jobs/{jobId}.
const { path, mode, result } = event.data;
const flagged = result.review?.flagged || [];
console.log(`[${deliveryId}] ${path} (${mode})`, result.values);
if (flagged.length) {
// Each entry is { path, reasons }; cells[path] holds box / quad / evidence.
console.warn(
`${flagged.length} field(s) to review:`,
flagged.map((f) => `${f.path} -> ${f.reasons[0]}`),
);
}
// TODO: post result.values to your accounting system, and hold the
// flagged paths for a person before the invoice is approved.
break;
}
case 'ocr.failed':
// The scan is refunded automatically; keep the payload for the audit trail.
console.error(`[${deliveryId}] OCR failed:`, event.data);
break;
case 'webhook.test':
console.log(`[${deliveryId}] test delivery received`);
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
// Anything other than 2xx is retried: 1m -> 5m -> 30m -> 2h, 5 attempts in total.
// Keep this fast — hand slow work to a queue instead of holding the response open.
res.status(200).send({ received: true });
});
app.listen(3000, () => console.log('Webhook receiver listening on port 3000'));space-ocr 是如何在頁面上定位資料的?底層的語言模型會回傳擷取出的文字,並附上其可能位置的 token 提示。接著,我們的引擎會執行一個關鍵的驗證步驟:將擷取出的值,逐一字元地與頁面上實際偵測到的 OCR 符號進行比對。這個過程會產生一個介于 0.0 到 1.0 之間的 match_ratio 分數。分數若達到 0.85 或更高,即表示為高信度的匹配。最終的座標會以 0-1000 的標準化邊界框(bounding box)形式回傳,與原始圖片的像素尺寸無關。
我們認為,建立這類自動化流程的門檻不應過高。透過 API 處理的每張圖片,費用為 $0.05(含稅)。每個帳戶每月還享有 100 次的免費掃描額度。更重要的是,如果 OCR 任務因圖片無法辨識而失敗,您完全不需付費。費用只與成功的資料擷取掛鉤,讓您能以低風險的方式開始自動化您的應付帳款作業。
- 公開一個公共端點您的伺服器需要一個公開的網址。在本地開發時,可以使用 ngrok 這類服務將您的本機伺服器暴露到網際網路上。
- 註冊您的 Webhook 網址在 space-ocr 儀表板的 Webhook 設定中新增您的公開端點網址,或向 `PUT /webhook` 送出 `url` 與 `active: true` 進行註冊。簽章密鑰只有在首次產生或以 `rotateSecret` 重新簽發時,才會以明文回傳一次,請立即妥善保存。
- 實作簽章驗證將 `X-Spaceocr-Signature` 依 `t=<unix_ms>,v1=<hex>` 解析,以密鑰對 `${t}.${rawBody}` 計算 HMAC-SHA256 並與 `v1` 做定時比較,timestamp 偏差超過 5 分鐘就拒絕。實作完成後,用 `POST /webhook/test` 送一次測試傳送,確認整條路徑。
- 處理「ocr.completed」事件當一個有效的事件送達時,讀取 `data.result`(`{ values, cells, review, image }`)。把 `values` 寫入您的會計系統,並將 `review.flagged` 中的每一筆(含 `path` 與依排序排列的 `reasons`)交由人工確認,同時可用 `cells[path]` 指出該值在頁面上的位置。
- 非同步上傳發票向 `POST /upload` 發起一個包含您檔案的請求(單次最多 20 個)。API 會立即回傳帶有每個檔案 `jobId` 的 `jobs[]`,而不會等待 OCR 完成。
- 確認事件並進行監控您的端點應快速回傳 2xx 狀態碼以確認收到事件;非 2xx 會依 1 分鐘 → 5 分鐘 → 30 分鐘 → 2 小時 的排程重試。您可以透過 `GET /webhooks/deliveries`(以 status 篩選,查看 `attempts` 與 `responsePreview`)或儀表板追蹤傳送狀況,失敗的傳送可用 `POST /webhooks/deliveries/{deliveryId}/redeliver` 重新送出。