How to Reconcile Supplier Invoices Against Open NetSuite Purchase Orders with AI
Catch a supplier invoice the moment it arrives by email, extract its line items, pull the matching open NetSuite purchase order, and let Spojit perform a three-way match that flags price or quantity variances straight to Slack.
What This Integration Does
Suppliers email invoice PDFs that have to be checked against what you actually ordered before anyone pays them. Doing that by hand means opening each PDF, finding the right purchase order in NetSuite, and comparing every line for the unit price you agreed and the quantity you ordered. This Spojit workflow does the comparison for you: it reads the invoice, fetches the open purchase order it references, and produces a structured verdict on every line so your accounts payable team only looks at the invoices that disagree with the order.
The workflow runs on a Mailhook trigger, so it fires within seconds of a supplier emailing a dedicated address. An Attachment node grabs the PDF bytes, a Knowledge node embeds and queries that single document in a transient collection to extract line items, and a Connector node on the netsuite connector pulls the purchase order named on the invoice. A second Connector node in Agent mode with a Response Schema performs the three-way match and returns clean JSON; if it finds variances, a final Connector node on the slack connector posts them. Each email is processed once (Spojit deduplicates per message), nothing is written back to NetSuite, and re-sending the same invoice simply re-runs the read-only check.
Prerequisites
- A netsuite connection in Spojit with permission to read purchase orders (the
get-recordtool reads thepurchaseOrderrecord type). - A slack connection authorized to post to the channel where your AP team watches for exceptions.
- A workflow whose Trigger is set to Mailhook with a generated address you can give to suppliers or forward invoices to.
- Your suppliers send invoices as PDF attachments, and those invoices print the related purchase order number (a PO reference) somewhere on the page.
- The Slack channel ID (for example
C0123ABCD) you want exception alerts delivered to.
Step 1: Receive the invoice email with a Mailhook trigger
Open the Trigger node, set Trigger Type to Mailhook, choose an Address prefix such as ap-invoices, then click Generate email address. Copy the generated address (it looks like ap-invoices-3f8a1c9d4b2e7a06@mailhook.spojit.com) and have suppliers send invoices there, or add a forwarding rule from your AP inbox. To keep stray mail out, set a From allowlist of your supplier domains and an optional Subject regex like (?i)invoice. The trigger output is available downstream as {{ input }}, including {{ input.subject }}, {{ input.from }}, {{ input.replyTo }}, and the {{ input.attachments }} references.
Step 2: Fetch the PDF bytes with an Attachment node
Add an Attachment node (the designer only allows this node when the trigger is a Mailhook). Set Mode to Single so you get the first matching file as an object, set the Content type filter to application/pdf, and set a Filename pattern of *.pdf. Turn on Fail if no attachment matches so an email with no invoice PDF stops cleanly instead of running the AI steps on nothing. The node outputs { filename, contentType, size, content }, where content is the base64 file body you feed into the next step as {{ attachment.content }}.
Step 3: Extract invoice line items with a Knowledge node (transient)
Add a Knowledge node in Embed mode. In the Collection dropdown choose Transient so the document lives only for this run and is cleaned up afterward (no file name or persistent collection needed). Set Document Type to PDF and point Document Input at {{ attachment.content }}.
Add a second Knowledge node in Query mode, again with Collection set to Transient so it reads the document you just embedded in the same run. Give it a Prompt that asks for the structured fields you need, and attach a Response Schema so the answer comes back as predictable JSON:
{
"type": "object",
"properties": {
"purchaseOrderNumber": { "type": "string" },
"invoiceNumber": { "type": "string" },
"vendorName": { "type": "string" },
"currency": { "type": "string" },
"lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"description": { "type": "string" },
"quantity": { "type": "number" },
"unitPrice": { "type": "number" },
"lineTotal": { "type": "number" }
}
}
}
}
}
Set the Output Variable to invoice so later steps reference {{ invoice.purchaseOrderNumber }} and {{ invoice.lines }}.
Step 4: Pull the matching open NetSuite purchase order
NetSuite purchase orders are addressed by internal ID, while suppliers usually print a human-readable PO number. Add a Connector node in Direct mode on the netsuite connector and pick run-suiteql to resolve the printed number to an internal ID, passing the value from the invoice into the query:
SELECT id, tranid, status, entity
FROM transaction
WHERE type = 'PurchOrd' AND tranid = '{{ invoice.purchaseOrderNumber }}'
Set the Output Variable to poLookup. Then add a second Connector node in Direct mode on the same netsuite connector using get-record with recordType set to purchaseOrder and id set to {{ poLookup.items.0.id }}. Turn on expandSubResources so the response includes the order's item lines with their ordered quantities and agreed unit prices. Save its output to {{ purchaseOrder }}. If you want to skip closed orders, add a Condition node before this step that only continues when the SuiteQL status indicates the PO is still open.
Step 5: Perform the three-way match in Agent mode with a Response Schema
Add a Connector node in Agent mode on the netsuite connector. Agent mode lets the agent reason across the two structured inputs and return a forced JSON verdict. In the prompt, give it the extracted invoice and the fetched purchase order and ask it to match line by line on SKU, comparing ordered quantity against billed quantity and agreed unit price against billed unit price:
Compare this supplier invoice against the open purchase order.
Match lines by SKU. For each line, report whether the billed
quantity and unit price agree with the ordered quantity and
agreed price. Flag any price variance, quantity variance, or
line that appears on the invoice but not the purchase order.
Invoice: {{ invoice }}
Purchase order: {{ purchaseOrder }}
Attach a Response Schema so downstream nodes can branch reliably:
{
"type": "object",
"properties": {
"matched": { "type": "boolean" },
"purchaseOrderNumber": { "type": "string" },
"variances": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku": { "type": "string" },
"issue": { "type": "string", "enum": ["price", "quantity", "unmatched"] },
"ordered": { "type": "number" },
"billed": { "type": "number" },
"detail": { "type": "string" }
}
}
}
}
}
Set the Output Variable to match. When {{ match.matched }} is true and {{ match.variances }} is empty, the invoice agrees with the order.
Step 6: Post mismatches to Slack
Add a Condition node that continues only when {{ match.matched }} is false. On the true branch, add a Connector node in Direct mode on the slack connector and pick send-message. Set the channel field to your AP channel ID (for example C0123ABCD) and build the text from the match result:
:warning: Invoice {{ invoice.invoiceNumber }} from {{ invoice.vendorName }}
does not match PO {{ match.purchaseOrderNumber }}.
Variances: {{ match.variances }}
Optionally, on the matched (false) branch you can add a Send Email node to acknowledge the supplier at {{ input.replyTo }}, since the Mailhook trigger is always asynchronous and never replies to the sender on its own.
Tips
- Use a transient collection for the invoice rather than a persistent one: it is created per run, shared across the embed and query nodes in that run, and cleaned up automatically, which is ideal for one-off invoice extraction.
- Keep the heavy reasoning in a single Agent-mode node. Direct mode handles the predictable NetSuite and Slack calls at no AI cost, so you only spend credits on the match itself.
- If suppliers occasionally send several invoices in one email, switch the Attachment node to
Multiplemode and wrap the extraction and matching steps in a Loop over{{ attachment.attachments }}. - Tighten the Slack message by listing only the SKUs in
{{ match.variances }}so reviewers see the exception lines at a glance.
Common Pitfalls
- NetSuite needs the internal ID, not the printed PO number. Resolve the number with
run-suiteqlfirst; callingget-recorddirectly with the printedtranidwill not find the record. - If
get-recordreturns lines without quantities and prices, you likely leftexpandSubResourcesoff, so the match has nothing to compare against. - Attachment limits apply: up to 10 MB per attachment and 25 MB per run by default, and received emails are retained for 30 days, so reprocess promptly.
- Always embed and query the transient collection inside the same run. A separate run cannot read a previous run's transient documents.
- Validate that the invoice currency matches the PO currency before trusting a price comparison; add the
currencyfield to your prompt so the agent can flag a currency mismatch instead of a false price variance.
Testing
Before pointing live supplier mail at the address, send yourself one real invoice PDF whose PO you know is open in NetSuite, then watch the run in the execution history. Confirm the Attachment node captured the PDF, the transient Knowledge query populated {{ invoice.lines }}, get-record returned the purchase order with its lines, and the Agent-mode {{ match }} output is well-formed JSON. Deliberately email an invoice with a wrong unit price to verify the variance reaches Slack, and one that matches exactly to verify nothing is posted. Once both paths behave, add your supplier domains to the From allowlist and turn the workflow on.