如何利用 OCR API 和 Webhook 构建应付账款自动化流程
了解如何构建一个可靠的应付账款自动化流程。通过异步 OCR API 上传发票,并使用安全的 Webhook 接收结构化数据。
处理发票是典型的业务瓶颈。手动录入数据不仅慢,还容易出错。即使使用了 OCR API,采用轮询方式检查任务是否完成也效率低下,这会增加系统的复杂性、延迟和不必要的网络流量。一个真正自动化的应付账款工作流不应该需要反复查询“好了吗?”,而应是事件驱动的,在数据就绪时立即响应。

这就是 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。当您需要重试网络请求时,发送相同的 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 范围内的归一化边界框形式返回,与原始图像的像素尺寸无关。
我们致力于让这种自动化技术普及化。通过 API 处理的每张图片,费用为 $0.05(含税)。每个账户每月还享有 100 次免费扫描额度。重要的是,如果 OCR 任务因图像无法识别而失败,我们不会收取任何费用。成本与成功提取数据直接挂钩,这让您能以低风险的方式开始您的应付账款自动化。
- 暴露一个公共端点您的服务器需要一个公共 URL。在本地开发时,可以使用 ngrok 等服务将您的本地服务器暴露到互联网上。
- 注册您的 Webhook URL在 space-ocr 控制面板的 Webhook 设置中添加您的公共端点 URL,或者向 `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` 重新发送。