How to Capture Emailed Expense Receipts into a Spreadsheet Pipeline with a Mailhook

Forward a receipt email to a Spojit mailhook address, read the attached PDF receipt, extract a structured expense row with Miraxa, and append it to a running CSV ledger stored on your FTP server.

What This Integration Does

Teams collect expense receipts as PDFs attached to emails: a coffee, a taxi, a software subscription. Re-keying each one into a spreadsheet is slow and error-prone. This workflow turns a forwarded receipt email into a single ledger row automatically. Anyone in your organization forwards (or sets a vendor rule to send) the receipt to a dedicated mailhook address, and within seconds Spojit reads the PDF, pulls out the merchant, date, amount, currency, and category, and appends a row to a shared CSV that lives on your FTP server. The result is a tidy, append-only expense ledger that finance can pull at any time.

The workflow is triggered by the Mailhook trigger, so it runs the moment mail arrives at a unique Spojit-generated address: no mailbox, no polling, no OAuth. The receipt bytes are fetched with an Attachment node, parsed to text with the pdf connector, and structured into a clean object with a Knowledge node querying a Transient collection. The new row is merged into the existing ledger and written back to the same file on FTP. Each email is deduplicated, so forwarding the same receipt twice does not create a duplicate run. Because the ledger is read, appended, and re-uploaded on every run, the file grows by exactly one row per receipt and stays the single source of truth for the period.

Prerequisites

  • An FTP connection added under Connections -> Add connection, with read and write access to the directory that will hold your ledger file (for example /finance/expenses/).
  • A starter ledger CSV already on the FTP server (for example /finance/expenses/ledger.csv) with a header row such as date,merchant,amount,currency,category,source_email. The workflow appends to whatever is already there.
  • A clear idea of who will send receipts to the mailhook address, so you can set an optional From allowlist later.
  • The pdf, csv, and ftp connectors are available in your workspace (the pdf and csv utilities are built in and need no connection).

Step 1: Create the workflow and add the Mailhook trigger

Create a new workflow, then open the trigger node and set Trigger Type to Mailhook. Optionally set an Address prefix (1 to 24 characters, default mh) such as receipts, then click Generate email address. Spojit produces a unique address like receipts-a1b2c3d4e5f6g7h8@mailhook.spojit.com. Copy it and share it with your team or point a vendor forwarding rule at it. To reduce noise, add a From allowlist (only mail from those senders runs the workflow) and an optional Subject regex. The trigger fires whether the address is in To, Cc, or Bcc, and the full email is available downstream as {{ input }}, including {{ input.from }}, {{ input.subject }}, {{ input.replyTo }}, and {{ input.attachments }}.

Step 2: Fetch the receipt PDF with an Attachment node

Add an Attachment node directly after the trigger. This node only works in Mailhook workflows and fetches the actual bytes of an attachment the trigger referenced. Set Mode to Single so you get the first match as an object. Set the Content type filter to application/pdf and the Filename pattern to *.pdf so only the receipt is pulled. Turn on Fail if no attachment matches so an email with no PDF stops cleanly instead of producing an empty row. The node output looks like this:

{
  "filename": "receipt-2026-06-20.pdf",
  "contentType": "application/pdf",
  "size": 48213,
  "content": "JVBERi0xLjQ..."   // base64
}

If a sender attaches several receipts in one email, switch Mode to Multiple and wrap the following steps in a Loop over {{ attachment.attachments }}. For the rest of this tutorial we assume one receipt per email.

Step 3: Extract the receipt text with the pdf connector

Add a Connector node in Direct mode, choose the pdf connector, and select the extract-text tool. Map the tool's pdf input to the base64 bytes you just fetched: {{ attachment.content }}. The tool returns a text field containing the concatenated text of every page. Save the result so it is available as {{ pdf_text.text }}. Most digitally generated receipts produce clean, readable text here, which is what the next step turns into a structured row.

Step 4: Embed the receipt into a Transient collection

Add a Knowledge node in Embed mode. In the Collection dropdown pick Transient: a per-run collection that is auto-created, shared across nodes in the same run, and auto-cleaned when the run finishes, so there is no leftover document and no file name to manage. Set Document Type to Plain Text and point the Document Input at the extracted receipt text. Because Document Input expects a base64 reference, you can instead feed the original PDF directly: set Document Type to PDF and Document Input to {{ attachment.content }}. Either approach works; embedding the PDF directly skips Step 3 if you prefer fewer nodes. Save the chunk count to an Output Variable so you can confirm the receipt was embedded.

Step 5: Extract a structured expense row with a Knowledge query

Add a second Knowledge node in Query mode, and select Transient again so it reads the receipt embedded earlier in the same run. This is where Miraxa, the intelligent layer across your automation, reads the receipt and returns clean fields. Set the Prompt to something specific:

Extract the expense details from this receipt. Return the
transaction date in YYYY-MM-DD format, the merchant name,
the total amount as a number with no currency symbol, the
3-letter currency code, and a short expense category such
as Meals, Travel, Software, or Office.

Use the Response Schema to force a predictable JSON object so the next steps never have to guess at the shape:

{
  "type": "object",
  "properties": {
    "date":     { "type": "string" },
    "merchant": { "type": "string" },
    "amount":   { "type": "number" },
    "currency": { "type": "string" },
    "category": { "type": "string" }
  },
  "required": ["date", "merchant", "amount", "currency"]
}

Save the result to an Output Variable named expense so the fields are available as {{ expense.date }}, {{ expense.merchant }}, and so on.

Step 6: Download the current ledger and append the new row

Add a Connector node in Direct mode, choose the ftp connector, and select download-file. Set the path input to your ledger location, for example /finance/expenses/ledger.csv. The tool returns the file in data.content; save it as {{ ledger }}. Next, add a Connector node with the csv connector and the from-json tool to turn your single new expense into a one-row CSV. Map its data input to an array holding the new row:

[
  {
    "date":         "{{ expense.date }}",
    "merchant":     "{{ expense.merchant }}",
    "amount":       "{{ expense.amount }}",
    "currency":     "{{ expense.currency }}",
    "category":     "{{ expense.category }}",
    "source_email": "{{ input.from }}"
  }
]

Set columns to date,merchant,amount,currency,category,source_email to lock the column order, and turn the header row off so you only emit the data line (the existing ledger already has its header). Then add a Transform node to concatenate the downloaded ledger and the new line, ensuring a single newline between them, producing {{ updated_ledger }}.

Step 7: Upload the updated ledger back to FTP

Add a final Connector node in Direct mode with the ftp connector and the upload-file tool. Set path to the same ledger location, /finance/expenses/ledger.csv, and map content to {{ updated_ledger }}. Leave encoding as utf8 since the ledger is text. The tool overwrites the file at that path, so the ledger ends up exactly one row longer. To close the loop for the person who sent the receipt, add a Send Email node addressed to {{ input.replyTo }} confirming the expense was logged, since mailhook runs are always asynchronous and never reply to the sender on their own.

Tips

  • Embedding the PDF directly in the Knowledge Embed node (Document Type PDF, Document Input {{ attachment.content }}) lets you skip the separate extract-text step when receipts are clean digital PDFs.
  • Keep the Response Schema tight (only the fields you store) so the structured output stays stable even when receipts vary wildly in layout.
  • Use a memorable Address prefix like receipts when you generate the mailhook address so it reads clearly in forwarding rules and vendor settings.
  • Attachment limits default to 10 MB per attachment and 25 MB per run, which is comfortably above a typical PDF receipt; received emails are retained for 30 days if you ever need to re-run one.

Common Pitfalls

  • Scanned or photographed receipts may extract poorly with extract-text. For image-only receipts, embed the file with Document Type Images via OCR in the Knowledge node instead of relying on PDF text extraction.
  • Forgetting to turn off the header row in the csv from-json step writes a stray date,merchant,... line into the middle of your ledger on every run. Keep header off for the appended row.
  • The upload-file tool overwrites the whole file, so the download-append-upload sequence must run in order. If two receipts arrive in the exact same second, run them through a queue or accept that the last write wins; for high volume, consider a dedicated ledger file per day.
  • An email with no PDF attachment produces an empty Attachment match. Turn on Fail if no attachment matches so those runs stop instead of writing a blank row.

Testing

Before sharing the address widely, point a temporary copy of the ledger at a test directory like /finance/expenses/test-ledger.csv and forward yourself one real receipt PDF. Open the run in execution history and confirm the Attachment node fetched the right file, the Knowledge query returned the expected {{ expense.amount }} and {{ expense.merchant }}, and the uploaded file gained exactly one row. Forward the same email again to confirm deduplication prevents a second run. Once the test ledger looks correct, repoint the FTP paths at your real ledger and add the From allowlist. If anything looks off, ask Miraxa "Why did my last run fail?" to investigate the specific step.

Learn More

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