space ocr
指南文章價格文件
developer

如何利用 OCR API 與 Webhook 打造應付帳款自動化管道

了解如何建立一個可靠的應付帳款自動化管道。使用非同步 OCR API 上傳發票,並透過安全的 Webhook 接收結構化資料。

7 分鐘閱讀· 2026-08-31

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

一張正在通過應付帳款自動化流程的發票
發票輸入,結構化資料與 Webhook 輸出。

這正是 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 筆為主要原因。

webhook-receiver.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
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'));
✓ Verified

space-ocr 是如何在頁面上定位資料的?底層的語言模型會回傳擷取出的文字,並附上其可能位置的 token 提示。接著,我們的引擎會執行一個關鍵的驗證步驟:將擷取出的值,逐一字元地與頁面上實際偵測到的 OCR 符號進行比對。這個過程會產生一個介于 0.0 到 1.0 之間的 match_ratio 分數。分數若達到 0.85 或更高,即表示為高信度的匹配。最終的座標會以 0-1000 的標準化邊界框(bounding box)形式回傳,與原始圖片的像素尺寸無關。

我們認為,建立這類自動化流程的門檻不應過高。透過 API 處理的每張圖片,費用為 $0.05(含稅)。每個帳戶每月還享有 100 次的免費掃描額度。更重要的是,如果 OCR 任務因圖片無法辨識而失敗,您完全不需付費。費用只與成功的資料擷取掛鉤,讓您能以低風險的方式開始自動化您的應付帳款作業。

  1. 公開一個公共端點
    您的伺服器需要一個公開的網址。在本地開發時,可以使用 ngrok 這類服務將您的本機伺服器暴露到網際網路上。
  2. 註冊您的 Webhook 網址
    在 space-ocr 儀表板的 Webhook 設定中新增您的公開端點網址,或向 `PUT /webhook` 送出 `url` 與 `active: true` 進行註冊。簽章密鑰只有在首次產生或以 `rotateSecret` 重新簽發時,才會以明文回傳一次,請立即妥善保存。
  3. 實作簽章驗證
    將 `X-Spaceocr-Signature` 依 `t=<unix_ms>,v1=<hex>` 解析,以密鑰對 `${t}.${rawBody}` 計算 HMAC-SHA256 並與 `v1` 做定時比較,timestamp 偏差超過 5 分鐘就拒絕。實作完成後,用 `POST /webhook/test` 送一次測試傳送,確認整條路徑。
  4. 處理「ocr.completed」事件
    當一個有效的事件送達時,讀取 `data.result`(`{ values, cells, review, image }`)。把 `values` 寫入您的會計系統,並將 `review.flagged` 中的每一筆(含 `path` 與依排序排列的 `reasons`)交由人工確認,同時可用 `cells[path]` 指出該值在頁面上的位置。
  5. 非同步上傳發票
    向 `POST /upload` 發起一個包含您檔案的請求(單次最多 20 個)。API 會立即回傳帶有每個檔案 `jobId` 的 `jobs[]`,而不會等待 OCR 完成。
  6. 確認事件並進行監控
    您的端點應快速回傳 2xx 狀態碼以確認收到事件;非 2xx 會依 1 分鐘 → 5 分鐘 → 30 分鐘 → 2 小時 的排程重試。您可以透過 `GET /webhooks/deliveries`(以 status 篩選,查看 `attempts` 與 `responsePreview`)或儀表板追蹤傳送狀況,失敗的傳送可用 `POST /webhooks/deliveries/{deliveryId}/redeliver` 重新送出。
如果我的 Webhook 端點服務中斷了怎麼辦?
傳送會以指數退避重試:1 分鐘 → 5 分鐘 → 30 分鐘 → 2 小時,總共最多 5 次。只有 5xx 伺服器錯誤、408、429 或請求逾時會重試,其他 4xx 會立即判為 dead。傳送日誌保留 30 天,您也可以用 `POST /webhooks/deliveries/{deliveryId}/redeliver` 手動重送——deliveryId 會沿用同一組,狀態回到 pending。
如何驗證 Webhook 請求確實來自 space-ocr?
每次傳送都會帶上 `X-Spaceocr-Signature`,格式為 `t=<unix_ms>,v1=<hex>`,同時還有 `X-Spaceocr-Timestamp`、`X-Spaceocr-Event` 與 `X-Spaceocr-Delivery`。驗證方式是:以您的 Webhook 密鑰對 canonical 字串 `${t}.${rawBody}` 計算 HMAC-SHA256,再把 hex 與 `v1` 做定時比較(timing-safe),並在 timestamp 偏差超過 5 分鐘時拒絕。簽章的對象是原始內文,因此必須在 JSON 解析改寫它之前完成驗證。
我可以在一次 API 呼叫中上傳多張發票嗎?
可以。`POST /upload` 接受 multipart/form-data,單一請求最多 20 個 `files` 欄位,單一檔案上限 20MB,整個請求上限 28MB,超過會回傳 413。回應是每個檔案一筆的 `jobs[]` 陣列,其中的 `jobId` 可直接用 `GET /jobs/{jobId}` 查詢。若加上 `wait=true`,呼叫會變成同步:每張圖片最多等待 30 秒並回傳 `results[]`,尚未完成的項目會以 `pending` 狀態回來。
OCR 任務失敗會收費嗎?
不會。如果 OCR 任務失敗,我們會觸發一個 `ocr.failed` 事件,並自動退還該次掃描的費用。您只需為成功的擷取付費。
輪詢(Polling)和使用 Webhook 有什麼不同?
輪詢需要您反覆呼叫 `GET /jobs/{jobId}` 來檢查任務是否完成。Webhook 則是事件驅動的;我們的伺服器會在結果準備好的那一刻,立即將其推送到您的端點,這種方式效率更高,也能提供即時更新。
如何從我的應用程式安全地重試上傳?
`/upload` 端點支援 `Idempotency-Key` 標頭。如果您在 24 小時內發送相同的金鑰,您會收到原始的快取回應,而不會建立重複的任務,藉此避免重複處理。

開始自動化您的應付帳款流程

立即取得您的 API 金鑰,每月享 100 次免費掃描,開始打造您的自動化方案。

相關文章