How to Turn Vendor Invoice Emails into NetSuite Vendor Bills with a Mailhook

Give vendors a dedicated Spojit email address, then let an incoming invoice PDF flow straight into a matching NetSuite vendor bill with line items extracted by AI.

What This Integration Does

Accounts payable teams spend hours opening vendor invoice emails, reading the PDF, and keying the vendor, amounts, and line items into NetSuite by hand. This tutorial replaces that with a hands-off pipeline: you publish a unique Spojit mailhook address, ask vendors (or an internal forwarding rule) to send invoices there, and every message that lands creates a draft vendor bill in NetSuite with the line items already filled in. You keep a human approval step in the middle so finance signs off before anything posts.

The workflow runs on a Mailhook trigger, so it fires within seconds of an email arriving, with no mailbox or OAuth to maintain. The trigger hands you the message metadata and attachment references; an Attachment node pulls the PDF bytes; a Knowledge node embeds the document into a transient collection and queries it with AI to extract structured fields; a Human node pauses for approval; and a Connector node writes the vendor bill into NetSuite. Each email is deduplicated, so a forwarded duplicate will not create two bills, and received emails are retained for 30 days if you need to re-check a run.

Prerequisites

  • A NetSuite connection added under Connections, with permission to create vendorBill records and to run lookups. See the NetSuite connector article for setup.
  • Your NetSuite vendor list available so you can match an invoice's "bill from" name to a vendor internal ID. The netsuite connector's run-suiteql and list-customers-style lookups are used here against vendor records.
  • An AI model available to your workspace for the Knowledge query step. AI extraction consumes credits.
  • Optional: a Stripe connection if you also want to confirm whether the invoice has already been paid before you create the bill. See the Stripe connector article.
  • An understanding of how the Mailhook trigger and Attachment node work, covered in Setting Up a Mailhook Trigger.

Step 1: Create the workflow and set a Mailhook trigger

Create a new workflow, then open the Trigger node and set Trigger Type to Mailhook. Set an Address prefix such as ap-invoices (1 to 24 characters), then click Generate email address. Spojit produces a unique address in the form ap-invoices-<random16>@mailhook.spojit.com. Copy it and give it to your vendors or point a forwarding rule at it. To cut noise, add a From allowlist of known vendor domains and an optional Subject regex like invoice|inv-. The trigger output is available downstream as {{ input }} and includes from, subject, text, replyTo, and attachments[], where each attachment reference is { id, filename, contentType }.

Step 2: Pull the invoice PDF with an Attachment node

Add an Attachment node directly after the trigger. The designer only allows this node when a Mailhook trigger is present. Set Mode to Single so you get the first matching file as one object, set the Content type filter to application/pdf, and set a Filename pattern of *.pdf if vendors sometimes attach extra files. Turn on Fail if no attachment matches so a body-only email does not silently produce an empty bill. The node returns:

{
  "filename": "acme-invoice-4471.pdf",
  "contentType": "application/pdf",
  "size": 184213,
  "content": "JVBERi0xLjQKJ..."
}

The content field is base64 and feeds straight into the next node. Remember the per-attachment limit is 10 MB and the per-run limit is 25 MB by default.

Step 3: Embed the PDF into a transient collection

Add a Knowledge node in Embed mode. In the Collection dropdown choose Transient so the document is created for this run only, shared with later nodes in the same run, and cleaned up automatically afterward. A transient collection needs no file name or embedding model. Set Document Type to PDF and set Document Input to {{ attachment.content }} (the base64 from Step 2). Pick an Output Variable such as embedResult so you can confirm the chunk count in the logs. This step turns the invoice into searchable chunks without permanently storing the vendor document.

Step 4: Extract structured fields with a Knowledge query

Add a second Knowledge node in Query mode and again select Transient as the Collection so it queries the document embedded in Step 3. Set a Prompt that names exactly the fields you need, set a Model for synthesis, and add a Response Schema so the output is reliable JSON rather than prose. A good prompt and schema look like this:

Prompt:
Extract the vendor name, the vendor invoice number, the invoice
date, the currency, every line item (description, quantity, unit
price, amount), the subtotal, the tax, and the total due.

Response Schema:
{
  "type": "object",
  "properties": {
    "vendorName": { "type": "string" },
    "invoiceNumber": { "type": "string" },
    "invoiceDate": { "type": "string" },
    "currency": { "type": "string" },
    "lineItems": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "description": { "type": "string" },
          "quantity": { "type": "number" },
          "unitPrice": { "type": "number" },
          "amount": { "type": "number" }
        }
      }
    },
    "subtotal": { "type": "number" },
    "tax": { "type": "number" },
    "total": { "type": "number" }
  }
}

Set the Output Variable to invoice. Downstream you can read {{ invoice.total }}, {{ invoice.vendorName }}, and the {{ invoice.lineItems }} array. This extraction is Miraxa, the intelligent layer across your automation, reading the document on your behalf.

Step 5: Match the vendor to a NetSuite internal ID

NetSuite vendor bills reference a vendor by internal ID, not by name, so add a Connector node in Direct mode using the netsuite connector and the run-suiteql tool to look the vendor up by name. A query such as the one below returns the candidate vendor:

SELECT id, entityid, companyname
FROM vendor
WHERE LOWER(companyname) LIKE '%{{ invoice.vendorName }}%'

Bind the result to an output variable like vendorLookup. Add a Condition node that checks whether exactly one vendor was returned. If the lookup is empty or ambiguous, branch to a Send Email node that emails your AP team (and the sender via {{ input.replyTo }}) asking them to confirm the vendor, so the run never guesses.

Step 6: Pause for finance approval

Add a Human node so a person signs off before anything is written to NetSuite. Set a clear Label like Approve vendor bill and a Message that surfaces the key numbers, for example Approve {{ invoice.vendorName }} invoice {{ invoice.invoiceNumber }} for {{ invoice.total }} {{ invoice.currency }}?. Add at least one Approval slot with your AP role or named approvers, set an optional Timeout (minutes), and turn on Email approvers if your team works from email. Approvers respond in the Approvals inbox at /approvals. If approved, the run continues; if rejected or timed out, the run halts and no bill is created.

Step 7: Create the NetSuite vendor bill

Add a final Connector node in Direct mode using the netsuite connector and the create-record tool. Set recordType to vendorBill and build the body from your extracted data and the vendor internal ID from Step 5:

recordType: vendorBill
body:
{
  "entity": { "id": "{{ vendorLookup.items.0.id }}" },
  "tranId": "{{ invoice.invoiceNumber }}",
  "tranDate": "{{ invoice.invoiceDate }}",
  "memo": "Imported from emailed invoice via Spojit",
  "item": {
    "items": [
      {
        "description": "{{ lineItem.description }}",
        "quantity": "{{ lineItem.quantity }}",
        "rate": "{{ lineItem.unitPrice }}"
      }
    ]
  }
}

To add every extracted line, wrap the line-item portion in a Loop node set to ForEach over {{ invoice.lineItems }}, or use add-sublist-item on the netsuite connector to append each line to the bill you just created. If you want to avoid duplicate bills when a vendor re-sends, use upsert-record with the invoice number as externalId instead of create-record. Optionally finish with a Send Email node confirming the bill back to {{ input.replyTo }}.

Tips

  • Use the Transient collection for one-off invoice extraction so vendor documents are never stored long term. Reserve a persistent collection only if you genuinely want a searchable archive.
  • If you want a paid-status check, add a Connector node on the stripe connector with list-invoices or list-charges before approval, so reviewers can see whether the amount has already been collected.
  • Keep the Knowledge query Response Schema tight: number types for amounts prevent NetSuite rejecting strings where it expects currency values.
  • Set the Mailhook From allowlist to vendor domains so marketing mail or replies never start a run.

Common Pitfalls

  • The Attachment node will not save without a Mailhook trigger upstream, and a body-only email matches nothing unless you account for it. Turn on Fail if no attachment matches rather than letting an empty bill through.
  • Vendor names on invoices rarely match NetSuite exactly. Use a LIKE match in run-suiteql and a Condition node to catch zero or multiple matches instead of posting to the wrong vendor.
  • A rejected or timed-out Human approval halts the run. There is no "on reject" branch, so put any cleanup or notify-the-sender logic on the path that runs before approval, not after.
  • Attachments over 10 MB per file or 25 MB per run are dropped by default. Large multi-page invoices may need vendors to send a lighter PDF.

Testing

Before sharing the address with real vendors, send a single sample invoice PDF to the generated mailhook address from your own inbox and watch the run in the execution logs. Confirm the Attachment node returned the file, the Knowledge query produced clean JSON in {{ invoice }}, and the vendor lookup found the right internal ID. Keep the Human node and reject the first few test runs so nothing posts while you tune the prompt and schema. Once a real invoice produces a correct draft vendor bill end to end, widen the From allowlist and hand the address out.

Learn More

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.