Skip to content

Structured extraction

Extract invoice data from email and PDF into typed JSON

Define an invoice JSON Schema, attach it to a Postfleet mailbox, validate signed extraction webhooks, and route missing or risky data safely.

To extract invoice data from email and PDF attachments, define the JSON contract first, attach that schema to the receiving mailbox, and process only signed webhook events whose comprehension status is complete. Validate the extracted object again in your service, deduplicate both the webhook event and the vendor invoice, then send the result to review instead of directly scheduling payment.

The model's job is narrow: find fields supported by the message and available PDF text. Your application still decides whether the vendor, purchase order, totals, currency, and approval path are valid.

Decide what a usable invoice means#

Start with the action after extraction. An accounts-payable intake service may need four fields before it can create a review item:

  • the supplier's invoice number
  • the supplier name
  • the final amount due
  • the currency

Invoice date, due date, purchase order, tax, and line items are useful, but they may be absent from a valid invoice. Making every desirable field required turns ordinary documents into false non-matches.

The rule is simple: require a field only when the next step cannot proceed safely without it.

Use a closed invoice schema#

Create this extraction schema in the Postfleet dashboard:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "invoice_number": {
      "type": "string",
      "description": "Supplier invoice identifier exactly as printed"
    },
    "vendor_name": {
      "type": "string",
      "description": "Legal or trading name printed as the supplier"
    },
    "invoice_date": {
      "type": "string",
      "description": "Invoice issue date as YYYY-MM-DD when stated"
    },
    "due_date": {
      "type": "string",
      "description": "Payment due date as YYYY-MM-DD when stated or directly derivable from a stated invoice date and payment term"
    },
    "purchase_order": {
      "type": "string",
      "description": "Customer purchase order exactly as printed"
    },
    "currency": {
      "type": "string",
      "description": "ISO 4217 currency code supported by the invoice"
    },
    "subtotal": {
      "type": "number",
      "description": "Subtotal before tax when explicitly shown"
    },
    "tax": {
      "type": "number",
      "description": "Total tax when explicitly shown"
    },
    "total_due": {
      "type": "number",
      "description": "Final amount the invoice says is due"
    },
    "line_items": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "properties": {
          "description": { "type": "string" },
          "quantity": { "type": "number" },
          "unit_price": { "type": "number" },
          "line_total": { "type": "number" }
        },
        "required": ["description", "line_total"]
      }
    }
  },
  "required": [
    "invoice_number",
    "vendor_name",
    "currency",
    "total_due"
  ]
}

Postfleet accepts an object at the root with additionalProperties: false. It does not support $ref or recursive schemas. Closing the object prevents an unexpected model field from silently becoming part of your payable contract.

Field descriptions should settle source ambiguity. "Final amount the invoice says is due" is better than "invoice total" when a document also contains subtotal, tax, prior balance, and amount paid.

For the general design behind required fields and no-match behavior, read Email to JSON with AI: a JSON Schema-first guide.

Attach the schema to the receiving mailbox#

Save the schema, then assign it to the mailbox that receives invoices. A new mailbox can also be created with an extraction_schema_id through POST /api/v1/mailboxes; existing mailbox assignments are available in the dashboard.

Use a dedicated address such as ap-intake@your-domain.example instead of mixing invoices with support and sales mail. A focused mailbox improves routing, reduces ambiguous matches, and gives you a smaller key and review boundary.

If you are testing the flow for the first time, create a mailbox, send one body-only invoice, one text PDF, and one near-match such as a statement.

Know what enters extraction#

Postfleet cleans visible email text, includes PDF text when attachment bytes are available, screens the combined text for prompt injection, and only then runs schema extraction. The agent-facing result does not contain the pre-sanitization body.

PDF text is where many first tests go wrong:

  • A PDF with a text layer can yield text.
  • An image-only scan has no text layer and needs OCR outside the current extraction path.
  • An inbound provider may put only attachment metadata in its event. The ingestion layer must fetch the actual bytes before PDF text can be included.
  • A PDF parser failure does not block delivery. The message can complete using the email body, with no attachment text.
  • Extracted text is still untrusted content and goes through the same screen as the body.

Test the exact provider path before relying on PDF-only invoices. A successful body-only test does not prove that attachment bytes reached the parser.

Text extraction is not antivirus scanning. If you retain or expose original files, follow a separate file-security policy. OWASP recommends type and signature checks, size limits, sandboxing or antivirus where available, and content disarm and reconstruction for applicable PDFs.

Read the extraction from the webhook#

A matched invoice arrives in a signed message.received event:

{
  "event_id": "evt_9f8c...",
  "type": "message.received",
  "message_id": "m_123...",
  "mailbox_id": "b_456...",
  "from": "billing@northline.example",
  "subject": "Invoice INV-1049",
  "body": "cleaned, sanitized message body",
  "truncated": false,
  "sanitization": [],
  "comprehension_status": "complete",
  "classification": "invoice",
  "extraction": {
    "invoice_number": "INV-1049",
    "vendor_name": "Northline Components",
    "invoice_date": "2026-07-20",
    "due_date": "2026-08-19",
    "purchase_order": "PO-8821",
    "currency": "USD",
    "subtotal": 1700,
    "tax": 142.17,
    "total_due": 1842.17,
    "line_items": [
      {
        "description": "Replacement controller",
        "quantity": 2,
        "unit_price": 850,
        "line_total": 1700
      }
    ]
  },
  "extraction_error": null,
  "confidence": 0.96
}

The event body is already cleaned, but the webhook itself still needs authentication. Postfleet signs timestamp.rawBody with HMAC-SHA256. Verify the signature against the raw request body, reject timestamps more than five minutes old, and compare signatures in constant time. The full verifier is in the webhook documentation.

Validate and route the invoice in your service#

Install AJV for consumer-side JSON Schema validation:

npm install ajv

The handler below assumes signature verification has already succeeded. claimEvent must atomically insert the event_id and return false for a retry. invoiceExists enforces a separate business duplicate check because the same supplier invoice can arrive in two different emails with two different event IDs.

import Ajv from "ajv";
import invoiceSchema from "./invoice-schema.json" with { type: "json" };

type Invoice = {
  invoice_number: string;
  vendor_name: string;
  currency: string;
  total_due: number;
  purchase_order?: string;
  subtotal?: number;
  tax?: number;
};

type InvoiceEvent = {
  event_id: string;
  message_id: string;
  type: "message.received";
  from: string;
  comprehension_status: "complete" | "partial" | "skipped_injection_risk";
  classification: string | null;
  extraction: unknown | null;
  extraction_error: string | null;
  confidence: number | null;
};

const validate = new Ajv({ allErrors: true }).compile<Invoice>(invoiceSchema);

export async function ingestInvoice(event: InvoiceEvent) {
  if (!(await claimEvent(event.event_id))) {
    return { action: "already_processed" as const };
  }

  if (event.comprehension_status === "skipped_injection_risk") {
    return queueForReview(event.message_id, "injection_risk");
  }

  if (event.comprehension_status === "partial") {
    return queueForReview(
      event.message_id,
      event.extraction_error ?? "partial_comprehension",
    );
  }

  if (event.extraction === null) {
    if (event.classification === null) {
      return queueForReview(event.message_id, "extraction_not_run");
    }
    return { action: "schema_did_not_match" as const };
  }

  if (!validate(event.extraction)) {
    return deadLetter(event.event_id, validate.errors ?? []);
  }

  const invoice = event.extraction;
  if (invoice.total_due <= 0 || !/^[A-Z]{3}$/.test(invoice.currency)) {
    return queueForReview(event.message_id, "invalid_amount_or_currency");
  }

  if (await invoiceExists(invoice.vendor_name, invoice.invoice_number)) {
    return queueForReview(event.message_id, "possible_duplicate_invoice");
  }

  return createInvoiceReview({
    sourceMessageId: event.message_id,
    sender: event.from,
    invoice,
    extractionConfidence: event.confidence,
  });
}

The storage functions are application-specific, but their order is not. Claim the delivery before a side effect, stop on an unsafe pipeline state, validate the object, check business duplicates, then create a review item. Make the claim and durable database write one transaction or inbox-outbox operation. A crash after a standalone claim must not make the retry disappear.

Do not convert complete into "approved." It means the comprehension pipeline completed. It does not mean the supplier is known, the bank details are legitimate, or the goods were received.

Reconcile fields before approval#

Schema-valid invoice data can still be wrong. Apply checks that use records outside the email:

  • Match the normalized supplier to your vendor master.
  • Compare the sender domain with the contact information on file, but do not use the domain as the only identity proof.
  • Look up the purchase order and confirm it belongs to that supplier.
  • Recalculate line totals, subtotal, tax, and total using decimal arithmetic.
  • Confirm the currency is allowed for the supplier and purchasing entity.
  • Flag a new bank account or remittance instruction for independent verification.
  • Require a person to approve high-value, first-time, unmatched, or low-confidence invoices.

Money should not rely on binary floating-point arithmetic. Parse extracted amounts into a decimal type or convert them to integer minor units after confirming the currency's exponent.

Keep the failure states distinct#

An absent extraction has several meanings. Do not collapse them into one retry loop.

Status Extraction Likely meaning Action
complete Invoice object Required fields were found and constrained output completed Validate, reconcile, and review
complete null With a classification, the schema did not match. Without one, extraction may not have run because the schema or model capacity was unavailable Route a null classification to review; route a classified no-match elsewhere
partial Usually null The scan or extraction stage failed Inspect extraction_error; retry only a transient failure
skipped_injection_risk null Screening stopped extraction Hold for security review

If a required value is absent, Postfleet returns no extraction rather than inventing an empty string or zero. Do not work around that outcome by asking another model to guess from the raw file.

Test documents that break happy paths#

Your fixture set should include more than three clean supplier PDFs:

  • a scanned invoice with no text layer
  • an invoice whose email body and PDF show different totals
  • a statement containing several invoice numbers
  • a credit note with a negative amount
  • a forwarded thread containing an older invoice below the current message
  • a PDF with two currencies
  • an invoice missing one required field
  • duplicate delivery of the same webhook event
  • the same invoice attached to a new email
  • hidden or visible instructions aimed at the extraction model
  • a large message that sets truncated: true

For each fixture, assert the event status and your routing action. A useful test does not merely ask whether JSON was returned. It asks whether unsafe ambiguity reached the payable system.

Sources#

Continue reading

Put a trust boundary in front of the inbox.

Create a mailbox, issue the narrowest key the workflow needs, and inspect the cleaned message before your agent acts.