How to Build a PDF Extraction API

Build a PDF extraction workflow with a schema, resumable upload, asynchronous job, webhook, and structured row query.

PdfParse Team

PDF extraction is hard to get right. Correctness, data normalization, automation — miss any of them and the failure shows up downstream, in a back office or a pipeline that trusted the data.

Many extraction platforms answer this with classifiers and keyword rules: some correctness, plus a proprietary learning curve. PdfParse takes a different route. Extracted data lands in SQLite databases, so types and relationships are enforced by the database itself — and anyone with existing SQL knowledge can inspect, filter, and verify the result instead of learning a vendor's rule language.

Still, an extraction API that only returns page text is a text dump with extra steps. A useful one accepts a schema, uploads files safely, runs extraction outside the request cycle, and hands back structured rows.

This guide builds exactly that flow with the PdfParse REST API, extracting invoice fields and repeating line items along the way.

Six operations, start to finish:

Create schema

Upload PDF with TUS

Create extraction job

Poll status or receive webhook

Query structured rows

Validate the result

Before you start

You need:

  • Node.js 20 or later
  • a PdfParse project
  • a project API key
  • one synthetic or redacted invoice PDF

Create the key in Project → API Keys and store it in an environment variable. It never goes in browser code and never gets committed to source control.

export PDFPARSE_API_KEY="replace_with_your_project_key"
export PDF_FILE="./sample-invoice.pdf"

Every protected request uses this base URL and header:

const baseUrl = "https://api.pdfparse.net/v1";
const headers = {
  Authorization: `Bearer ${process.env.PDFPARSE_API_KEY}`,
  "Content-Type": "application/json",
};

1. Create the extraction schema

One parent table for the invoices, one child table for the repeating line items:

const schemaResponse = await fetch(`${baseUrl}/tables`, {
  method: "POST",
  headers: {
    ...headers,
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    name: "invoices",
    columns: [
      {
        name: "invoice_number",
        prompt: "Invoice number as printed",
        type: "text",
      },
      {
        name: "invoice_date",
        prompt: "Invoice date",
        type: "date",
      },
      {
        name: "vendor",
        prompt: "Vendor name",
        type: "text",
      },
      {
        name: "total",
        prompt: "Final invoice total without a currency symbol",
        type: "number",
      },
    ],
    childTables: [
      {
        name: "line_items",
        columns: [
          {
            name: "description",
            prompt: "Complete line-item description",
            type: "text",
          },
          {
            name: "quantity",
            prompt: "Line-item quantity",
            type: "number",
          },
          {
            name: "amount",
            prompt: "Line-item amount",
            type: "number",
          },
        ],
      },
    ],
  }),
});

if (!schemaResponse.ok) {
  throw new Error(await schemaResponse.text());
}

const invoiceTable = await schemaResponse.json();
console.log(invoiceTable.id, invoiceTable.slug);

PdfParse creates stable record identifiers and the parent-child relationship itself — never ask the extraction model to invent primary or foreign keys.

Use an idempotency key whenever you create resources or jobs. The API scopes replay protection to the project, method, and route for 24 hours, so a retried request can't create a duplicate.

2. Upload the PDF with TUS

The upload endpoint speaks TUS, the resumable upload protocol. With tus-js-client, a dropped connection costs you a retry instead of the whole file — and it beats stuffing a PDF into a JSON body.

npm install tus-js-client

This helper returns the document key the job endpoint needs:

import fs from "node:fs";
import path from "node:path";
import { Upload } from "tus-js-client";

function uploadPdf({ filePath, tableSlug, apiKey }) {
  const file = fs.createReadStream(filePath);
  const size = fs.statSync(filePath).size;
  const filename = path.basename(filePath);

  return new Promise((resolve, reject) => {
    const upload = new Upload(file, {
      endpoint: "https://api.pdfparse.net/v1/uploads",
      uploadSize: size,
      headers: {
        Authorization: `Bearer ${apiKey}`,
      },
      metadata: {
        filename,
        name: filename,
        type: "application/pdf",
        file_size: String(size),
        tableSlug,
      },
      retryDelays: [0, 1000, 3000, 5000],
      removeFingerprintOnSuccess: true,
      onError: reject,
      onProgress(bytesUploaded, bytesTotal) {
        const percent = Math.round((bytesUploaded / bytesTotal) * 100);
        process.stdout.write(`Upload ${percent}%\r`);
      },
      onSuccess() {
        const encodedUploadId = upload.url?.split("?")[0]?.split("/").filter(Boolean).at(-1);
        if (!encodedUploadId) {
          reject(new Error("The upload did not return an upload ID."));
          return;
        }
        const uploadId = decodeURIComponent(encodedUploadId);

        resolve({
          uploadId,
          documentKey: uploadId,
        });
      },
    });

    upload.start();
  });
}

Call it with the table slug from the schema response:

const uploaded = await uploadPdf({
  filePath: process.env.PDF_FILE,
  tableSlug: invoiceTable.slug,
  apiKey: process.env.PDFPARSE_API_KEY,
});

console.log(uploaded.documentKey);

Hang on to the upload URL if your client needs to resume an interrupted upload. And keep document contents and API keys out of your logs.

3. Create the extraction job

Create the job after every upload completes:

const jobResponse = await fetch(`${baseUrl}/jobs`, {
  method: "POST",
  headers: {
    ...headers,
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    tableSlug: invoiceTable.slug,
    documentKeys: [uploaded.documentKey],
  }),
});

if (!jobResponse.ok) {
  throw new Error(await jobResponse.text());
}

const job = await jobResponse.json();
console.log(`Job ${job.id}: ${job.status}`);

The job is asynchronous, so don't hold the upload request open while extraction runs. For a small integration, poll the job endpoint (the next step). For production, use webhooks (the step after).

4. Poll the job during development

A bounded loop that stops when the job completes or fails:

const wait = (milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function waitForJob(jobId) {
  for (let attempt = 0; attempt < 60; attempt += 1) {
    const response = await fetch(`${baseUrl}/jobs/${jobId}`, { headers });

    if (!response.ok) {
      throw new Error(await response.text());
    }

    const currentJob = await response.json();

    if (currentJob.status === "completed") return currentJob;
    if (currentJob.status === "failed") {
      throw new Error(`Extraction job ${jobId} failed.`);
    }

    await wait(2000);
  }

  throw new Error(`Extraction job ${jobId} did not finish in time.`);
}

await waitForJob(job.id);

Always set a time limit — forever is a long time to poll. If you hit a 429 or a temporary server error, back off exponentially instead of hammering the endpoint.

5. Use a webhook in production

Register an HTTPS endpoint for the events your service cares about:

const webhookResponse = await fetch(`${baseUrl}/webhooks`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    url: "https://example.com/webhooks/pdfparse",
    description: "Invoice extraction events",
    events: ["job.completed", "job.failed"],
  }),
});

if (!webhookResponse.ok) {
  throw new Error(await webhookResponse.text());
}

const webhook = await webhookResponse.json();
console.log("Store this secret once:", webhook.signing_secret);

The signing secret appears exactly once, in the create response. Store it in a secret manager — treat it like a password, because it is one.

PdfParse signs the exact request body with HMAC-SHA256, as <timestamp>.<rawBody>. Verify it like this:

import crypto from "node:crypto";

function verifyPdfParseWebhook({ rawBody, signatureHeader, secret }) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((part) => part.split("=")),
  );

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");

  const received = Buffer.from(parts.v1 ?? "", "hex");
  const calculated = Buffer.from(expected, "hex");

  return (
    received.length === calculated.length &&
    crypto.timingSafeEqual(received, calculated)
  );
}

Three rules: read the raw body before you parse JSON, reject stale timestamps according to your security policy, and deduplicate events with X-OCR-Webhook-Id.

Each request also carries:

X-OCR-Webhook-Event: job.completed
X-OCR-Webhook-Timestamp: 1784721600
X-OCR-Webhook-Signature: t=1784721600,v1=<hex_digest>

6. Query the extracted rows

Use the table ID from the schema or job response:

const rowsResponse = await fetch(`${baseUrl}/rows/search`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    table_id: invoiceTable.id,
    filters: [
      {
        column: "total",
        op: "gte",
        value: 100,
      },
    ],
    orderBy: {
      column: "invoice_date",
      direction: "desc",
    },
    page: 1,
    pageSize: 50,
  }),
});

if (!rowsResponse.ok) {
  throw new Error(await rowsResponse.text());
}

const result = await rowsResponse.json();
console.log(JSON.stringify(result, null, 2));

The search endpoint supports these filter operators:

eq  ne  gt  gte  lt  lte  like  in

Page size caps at 200, so paginate for anything larger.

7. Validate before downstream use

An API response can be perfectly valid JSON and still contain a wrong extracted value. Check:

  • Required fields are present.
  • Invoice numbers remain text.
  • Dates use one format.
  • Amounts are numbers.
  • Child rows link to the correct parent.
  • The line-item sum agrees with the invoice total when the document permits that check.
  • Duplicate webhook events do not create duplicate work.

Keep the source document reference with the result, and route uncertain or failed records to a review process.

Complete orchestration function

With each operation doing one job, the main flow stays small:

async function extractInvoice(filePath) {
  const table = await getOrCreateInvoiceTable();
  const upload = await uploadPdf({
    filePath,
    tableSlug: table.slug,
    apiKey: process.env.PDFPARSE_API_KEY,
  });
  const job = await createJob(table.slug, [upload.documentKey]);
  await waitForJob(job.id); // Replace with a webhook in production.
  return searchRows(table.id, []);
}

Make every operation retry-safe, keep credentials on the server, and use synthetic documents in tests.

Read the API documentation for the live endpoint reference. See authentication, the job guide, and the PDF to JSON page for related workflows.