Document Extraction API: A Developer's Guide with Webhooks

Document Extraction API: A Developer's Guide with Webhooks

If you need to turn invoices, receipts, or bank statements into structured data inside your own application, you want a clean document extraction API — not a UI you have to click through. This guide walks you from an API key to a production integration: submit a PDF over REST, receive structured JSON with field-level confidence scores, handle asynchronous results through HMAC-signed webhooks, and process high volume with the batch API.

This is a practical how-to for developers. If you're more interested in how the extraction engine works internally — the model orchestration, the position resolver, the fallback logic — read our invoice extraction architecture deep-dive instead. Here, we stay on the integration surface: endpoints, payloads, signatures, and the failure modes you'll actually hit in production.


Before you start: what you need

The ExtractBee API is available on Starter and above. The batch API is available on Pro and above. Here's how the developer surface maps to plans:

Capability Free Starter Business Pro
REST API access
HMAC-signed webhooks ❌ (UI/email only)
Batch API
Pages / month 20 300 3,000 10,000

To follow along you need an ExtractBee account on Starter or above, an API key (Dashboard → Settings → API Keys → Create Key), and a document to test with. Start free to create the account, then upgrade to Starter when you're ready to hit the API.

Treat the API key like a password. It goes in a server-side environment variable — never in client-side code, a mobile app bundle, or a committed .env file.


The mental model: extraction is asynchronous

The single most important thing to understand before writing code: extraction is a job, not a synchronous request. When you submit a document, ExtractBee runs OCR pre-processing (Tesseract for scanned files), sends the document to our AI model, resolves each field back to its bounding box, and scores confidence. That takes seconds, not milliseconds.

So the flow is:

  1. Submit a document → get back an extraction_id and a status of processing.
  2. Wait for completion via either a webhook (recommended) or polling.
  3. Read the structured result, including per-field confidence scores.

You can integrate this two ways:

  • Webhook-driven (recommended): ExtractBee POSTs the result to your endpoint when the job finishes. No polling, no wasted requests.
  • Polling: you submit, then GET the extraction every few seconds until status is completed. Simpler to prototype, but webhooks are how you run this in production.

We'll cover both.


Step 1: Submit a document

Send the file as multipart/form-data to the extractions endpoint. You can let ExtractBee auto-detect the document type, or pin it with document_type (one of invoice, receipt, bank_statement, contract, purchase_order).

curl -X POST https://api.extractbee.com/v1/extractions \
  -H "Authorization: Bearer $EXTRACTBEE_API_KEY" \
  -F "file=@/path/to/invoice.pdf" \
  -F "document_type=invoice" \
  -F "webhook_url=https://yourapp.com/webhooks/extractbee"

The same call in Node.js:

import fs from "node:fs";

const form = new FormData();
form.append("file", new Blob([fs.readFileSync("invoice.pdf")]), "invoice.pdf");
form.append("document_type", "invoice");
form.append("webhook_url", "https://yourapp.com/webhooks/extractbee");

const res = await fetch("https://api.extractbee.com/v1/extractions", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.EXTRACTBEE_API_KEY}` },
  body: form,
});

const job = await res.json();
// { "id": "ext_3kf9...", "status": "processing", "document_type": "invoice" }

You get back a job reference immediately:

{
  "id": "ext_3kf9a2bd71",
  "status": "processing",
  "document_type": "invoice",
  "created_at": "2026-05-28T09:14:02Z"
}

Store that id. It's your handle for the result, whether you poll for it or receive it by webhook.

Tip: Pass webhook_url per request to override your account-level webhook, or omit it to use the default endpoint configured in Integrations → Webhooks. Per-request URLs are handy for routing different document types to different services.


Step 2: Read the structured result

When the job finishes, the result looks like this (trimmed for clarity):

{
  "id": "ext_3kf9a2bd71",
  "status": "completed",
  "document_type": "invoice",
  "extracted_data": {
    "vendor_name": "Acme Corporation",
    "invoice_number": "INV-2026-0891",
    "invoice_date": "2026-05-15",
    "due_date": "2026-06-15",
    "currency": "EUR",
    "subtotal": 200.00,
    "vat_amount": 42.00,
    "total_amount": 242.00,
    "line_items": [
      { "description": "Product A", "quantity": 2, "unit_price": 100.00, "amount": 200.00 }
    ]
  },
  "confidence": {
    "vendor_name": 0.99,
    "invoice_number": 0.97,
    "total_amount": 0.99,
    "due_date": 0.62
  },
  "created_at": "2026-05-28T09:14:02Z",
  "completed_at": "2026-05-28T09:14:09Z"
}

Two things deserve attention.

Dates and numbers are normalized. Dates come back in ISO format (YYYY-MM-DD) and amounts as plain numbers with a . decimal separator, regardless of the source document's language or locale. You don't have to parse 15/05/2026 vs 2026-05-15 yourself.

Every field has a confidence score. The confidence object gives you a 0–1 score per field. In the example above, due_date came back at 0.62 — the model wasn't sure. This is the hook for your business logic: auto-approve high-confidence extractions, and route anything below your threshold to a human (or into the ExtractBee Human Review UI). We explain the methodology behind these numbers in our piece on how confidence scores work. For a field-by-field walk-through of what gets pulled from real invoices, see extracting data from PDF invoices.

A simple gate in your code:

const CONFIDENCE_THRESHOLD = 0.85;

const needsReview = Object.entries(result.confidence)
  .filter(([, score]) => score < CONFIDENCE_THRESHOLD)
  .map(([field]) => field);

if (needsReview.length > 0) {
  await queueForHumanReview(result.id, needsReview);
} else {
  await postToAccounting(result.extracted_data);
}

Step 3: Receive results via webhooks (recommended)

Polling works, but it doesn't scale and it wastes requests. In production, let ExtractBee push the result to you. When a job completes, we send a POST to your webhook_url with the full extraction payload and an event field:

{
  "event": "extraction.completed",
  "data": { "id": "ext_3kf9a2bd71", "status": "completed", "extracted_data": { ... } }
}

Other events you may receive: extraction.failed (the document couldn't be processed — bad scan, password-protected PDF, unsupported file) and, on Business+, events tied to email ingestion. Always switch on event rather than assuming success.

Verify the HMAC signature — every time

Your webhook endpoint is a public URL. Anyone who finds it could POST fake payloads. To prove a request genuinely came from ExtractBee, every webhook includes an HMAC-SHA256 signature computed over the raw request body using your webhook signing secret (Dashboard → Integrations → Webhooks → Signing Secret). Two headers come along for the ride:

  • X-ExtractBee-Signature — the hex-encoded HMAC-SHA256 of the raw body.
  • X-ExtractBee-Timestamp — the Unix timestamp when we sent it, to defend against replay attacks.

Here's a verified handler in Express. Note that you must use the raw body — if a JSON middleware re-serializes the payload, the bytes change and the signature won't match.

import express from "express";
import crypto from "node:crypto";

const app = express();
const SECRET = process.env.EXTRACTBEE_WEBHOOK_SECRET;

// Capture the raw body for signature verification.
app.use("/webhooks/extractbee", express.raw({ type: "application/json" }));

app.post("/webhooks/extractbee", (req, res) => {
  const signature = req.header("X-ExtractBee-Signature");
  const timestamp = req.header("X-ExtractBee-Timestamp");

  // 1. Reject stale requests (replay protection): 5-minute window.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > 300) return res.status(401).send("stale");

  // 2. Recompute the signature over timestamp + raw body.
  const signed = `${timestamp}.${req.body}`;
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(signed)
    .digest("hex");

  // 3. Constant-time compare to avoid timing attacks.
  const valid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
  if (!valid) return res.status(401).send("invalid signature");

  // 4. Acknowledge fast, process async.
  const payload = JSON.parse(req.body);
  res.status(200).send("ok");
  void handleEvent(payload);
});

The same verification in Python (Flask):

import hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["EXTRACTBEE_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/extractbee")
def webhook():
    signature = request.headers.get("X-ExtractBee-Signature", "")
    timestamp = request.headers.get("X-ExtractBee-Timestamp", "0")
    raw = request.get_data()  # bytes, before any parsing

    if abs(time.time() - int(timestamp)) > 300:
        abort(401)

    signed = f"{timestamp}.".encode() + raw
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(401)

    payload = request.get_json()
    # process payload async, then:
    return "ok", 200

Three rules to internalize: verify over the raw bytes, use a constant-time comparison (never ==), and acknowledge with a 2xx quickly then do your real work in the background. Webhook senders treat a slow or non-2xx response as a failure.

Retries and idempotency

If your endpoint doesn't return a 2xx within the timeout, ExtractBee retries with exponential backoff over the following minutes. That's good for reliability, but it means you may receive the same event more than once. Design your handler to be idempotent.

The simplest approach: dedupe on the extraction id (or the event ID, if you track one). Record processed IDs and short-circuit duplicates:

async function handleEvent(payload) {
  const id = payload.data.id;
  const firstTime = await markProcessed(id); // returns false if already seen
  if (!firstTime) return; // duplicate delivery — safely ignore
  await postToAccounting(payload.data.extracted_data);
}

Make markProcessed an atomic insert against a unique constraint (e.g. an INSERT ... ON CONFLICT DO NOTHING) so two concurrent retries can't both win. This single pattern eliminates almost every double-processing bug developers hit with webhooks. If you're building a broader pipeline, our guide to automating invoice processing covers the downstream routing and approval steps.

Polling, if you must

For prototypes or environments where you can't expose a public endpoint, poll the extraction until it completes:

async function waitForResult(id, { intervalMs = 2000, timeoutMs = 60000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await fetch(`https://api.extractbee.com/v1/extractions/${id}`, {
      headers: { Authorization: `Bearer ${process.env.EXTRACTBEE_API_KEY}` },
    });
    const job = await res.json();
    if (job.status === "completed" || job.status === "failed") return job;
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error("extraction timed out");
}

Keep the interval at a couple of seconds and always set a timeout. But once you go to production, switch to webhooks.


Step 4: Process volume with the batch API (Pro+)

When you're ingesting hundreds or thousands of documents at once — a month-end dump of invoices, a backlog migration — submitting them one at a time over HTTP is slow and chatty. The batch API, available on Pro and above, lets you enqueue many documents under a single batch and receive a single completion signal.

curl -X POST https://api.extractbee.com/v1/batches \
  -H "Authorization: Bearer $EXTRACTBEE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "document_type": "invoice",
    "documents": [
      { "url": "https://your-bucket.s3.amazonaws.com/inv-001.pdf", "reference": "inv-001" },
      { "url": "https://your-bucket.s3.amazonaws.com/inv-002.pdf", "reference": "inv-002" }
    ],
    "webhook_url": "https://yourapp.com/webhooks/extractbee"
  }'

You get back a batch_id and a status of processing. Each document carries your own reference, which is echoed back in every result so you can match extractions to your source records without maintaining a separate lookup. As individual documents finish, you receive extraction.completed events (each with its reference), and when the whole batch is done you receive a batch.completed event with a summary:

{
  "event": "batch.completed",
  "data": {
    "id": "batch_8a1c...",
    "total": 2,
    "completed": 2,
    "failed": 0
  }
}

A few batch-specific notes:

  • Submit by URL, not multipart, for large jobs. ExtractBee fetches each file. Use signed, short-lived URLs (S3 presigned URLs, R2 signed links) so the documents aren't publicly readable.
  • Pages count the same. Every page processed in a batch draws from your monthly page allowance like any other extraction; overage on Pro is 8¢ per page.
  • Per-document failures don't sink the batch. A bad scan fails on its own and reports in the failed count; the rest still complete.

Production checklist

Before you ship, make sure you've covered:

  • Keys in env vars, server-side only. Rotate the key if it ever leaks (Dashboard → API Keys).
  • HMAC verification on every webhook, over the raw body, with a timestamp/replay check and constant-time comparison.
  • Idempotent handlers keyed on the extraction id, with an atomic dedupe insert.
  • A confidence gate that routes low-confidence fields to review instead of trusting them blindly.
  • Handle extraction.failed — log it, alert if needed, and surface the document for manual handling.
  • Fast 2xx acknowledgements with real work deferred to a background queue.
  • Idempotent retries on your side too: if your own submit call times out, dedupe on a client-generated reference so you don't double-submit (and double-charge) the same file.

Frequently Asked Questions

Which plan do I need for the API? API access starts on the Starter plan (€35/month, 300 pages). HMAC-signed webhooks are available on Business and above. The batch API is available on Pro and above. The Free plan does not include API access.

Is extraction synchronous or asynchronous? Asynchronous. A submit returns an extraction_id and status: processing immediately; the structured result arrives a few seconds later via webhook or polling. Most documents complete in well under ten seconds, but you should never block a user request waiting on it.

How do I verify a webhook actually came from ExtractBee? Each webhook includes an X-ExtractBee-Signature header — an HMAC-SHA256 of the raw request body (combined with the X-ExtractBee-Timestamp) using your webhook signing secret. Recompute it on your side and compare with a constant-time function. Reject anything that doesn't match or whose timestamp is older than a few minutes.

What happens if my webhook endpoint is down? ExtractBee retries failed deliveries with exponential backoff over the following minutes. Because retries can also cause duplicate deliveries, make your handler idempotent by deduping on the extraction id. You can also fall back to polling the extraction endpoint for any results you missed.

Do confidence scores come through the API? Yes. Every extraction includes a confidence object with a 0–1 score per field. Use it to auto-approve high-confidence results and route low-confidence fields to human review. The scoring is provider-neutral — it works the same regardless of which model handled the document.

Which AI model does the API use? Extraction runs on our AI model — Google's Gemini 2.5 Flash as the primary engine, with Claude Sonnet 4.6 as an automatic fallback if the primary is unavailable. Scanned documents go through Tesseract OCR pre-processing first. This is all transparent to your integration: the request and response shape stay the same regardless of which model handled a given document.


Start building

The fastest path from zero to a working integration: create an account, generate an API key, and submit your first document. You'll have structured JSON with confidence scores back in seconds — then it's just wiring up a webhook handler and a confidence gate.

  1. Create a free ExtractBee account and upgrade to Starter for API access
  2. Generate an API key under Settings → API Keys
  3. POST your first invoice and inspect the JSON
  4. Add an HMAC-verified webhook handler (Business+) and ship

When you're ready to understand what's happening under the hood, the invoice extraction architecture write-up goes deep on the engine behind these endpoints.


Last updated: May 2026. ExtractBee is operated by MB Dokigo.