Firefly iii to Paperless NGX

Firefly iii to Paperless NGX

Over the past few weeks, I’ve been tinkering with Node-RED inside Home Assistant, and I think I’ve finally got a good handle on it.

At the same time, I’ve been exploring two other self-hosted tools to bring a bit more order to my digital life:

  • Paperless NGX – for managing and indexing my scanned documents
  • Firefly III – for tracking personal finances and expenses

Both are fantastic on their own, but I wanted something better: a way to automatically send transaction-related receipts and documents from Firefly III straight into Paperless NGX.

The goal?
A “set it and forget it” pipeline where new Firefly III attachments flow directly into my Paperless NGX archive without me manually downloading and uploading files.


Research & Inspiration

Before diving in, I did some digging to see if anyone had attempted this before.
I came across this guide by Shen Hong, which uses n8n for automation. It’s a great starting point — but since I’m running Node-RED in Home Assistant, I adapted the concept for my environment.

The result: a Node-RED flow that listens for new or updated transactions in Firefly III, retrieves the attachments, and uploads them into Paperless NGX.


Step 1 – Testing a Webhook Trigger

First, you’ll want a simple webhook trigger in Node-RED to confirm everything’s working before we connect it to Firefly III.

Here’s a basic config you can import into Node-RED:

[{"id":"f2dc2d714599bd10","type":"inject","z":"64526f0b19d3fed8","d":true,"name":"Trigger","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":"5","topic":"","payload":"","payloadType":"date","x":120,"y":540,"wires":[["8d15441eec058dd3"]]},{"id":"8d15441eec058dd3","type":"http request","z":"64526f0b19d3fed8","name":"Test of New","method":"POST","ret":"txt","paytoqs":"ignore","url":"https://homeassistant:8123/api/webhook/AAAAABBBBB","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":350,"y":540,"wires":[[]]}]

🔹 Replace homeassistant with your actual hostname or IP.
🔹 Use http instead of https if you’re not encrypting (but please, encrypt if you can).

NB. These are included in the full flow, but are commented out.


Step 2 – The Full Node-RED Flow

Once you know how your webhook works, you can import the full Node-RED flow I’ve built.

The Full node red flow

It listens for new or updated Firefly III transactions, fetches attachments, and sends them to Paperless NGX.

Here’s the high-level breakdown:

  1. Webhook triggers in Home Assistant – for new and updated Firefly III transactions.
  2. Retrieve attachments – Calls the Firefly III API to get the list of attachments for the transaction.
  3. Download each attachment – Uses HTTP requests to pull the file data.
  4. Post to Paperless NGX – Sends the document to Paperless NGX’s /post_document/ endpoint.

The full flow:

[{"id":"64526f0b19d3fed8","type":"tab","label":"Firefly iii to Paperless   NGX","disabled":false,"info":"Based On https://shen.hong.io/guide-to-workflow-automation-using-n8n-to-manage-receipts/\r\n","env":[]},{"id":"ceef98e738cb1365","type":"ha-webhook","z":"d8eae825f6375435","name":"New Webhook","server":"fed3ed8b.c9707","version":3,"exposeAsEntityConfig":"3e59278ed8264835","outputs":1,"webhookId":"AAAAABBBBB","method_get":false,"method_head":false,"method_post":true,"method_put":true,"outputProperties":[{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"},{"property":"payload","propertyType":"msg","value":"","valueType":"data"}],"payloadLocation":false,"payloadLocationType":false,"headersLocation":false,"headersLocationType":false,"x":140,"y":220,"wires":[["4c6e826e776250b2"]]},{"id":"2befdef53c089180","type":"ha-webhook","z":"d8eae825f6375435","name":"Update Webhook","server":"fed3ed8b.c9707","version":3,"exposeAsEntityConfig":"931b45abef410a9b","outputs":1,"webhookId":"CCCCCDDDDD","method_get":false,"method_head":false,"method_post":true,"method_put":true,"outputProperties":[{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"},{"property":"payload","propertyType":"msg","value":"","valueType":"data"}],"payloadLocation":false,"payloadLocationType":false,"headersLocation":false,"headersLocationType":false,"x":140,"y":380,"wires":[["4c6e826e776250b2"]]},{"id":"3b49833f267660d1","type":"http request","z":"d8eae825f6375435","name":"Post to Paperless NGX","method":"POST","ret":"txt","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":890,"y":420,"wires":[["b06080671d9ea397"]]},{"id":"1fc0753be7dc551b","type":"function","z":"d8eae825f6375435","name":"process attachments","func":"// Job is to get all the attachments, finish if none.\n\nvar index\nvar data = new Array();\n\nfor (index = 0; index < msg.payload.data.length; index++) {\n  let attachment = msg.payload.data[index]\n\n  \n  data[index]={\n      \"name\": attachment.attributes.filename, \n      \"url\": attachment.attributes.download_url\n  }\n}\n\nreturn {\n  \"payload\": data\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":400,"y":360,"wires":[["1a2c0b51c05bc935"]]},{"id":"c60140bebe809f23","type":"debug","z":"d8eae825f6375435","name":"Relabel Debug","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":940,"y":360,"wires":[]},{"id":"4cac35faa436999b","type":"http request","z":"d8eae825f6375435","name":"Test of New","method":"POST","ret":"txt","paytoqs":"ignore","url":"https://homeassistant:8123/api/webhook/AAAAABBBBB","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"","senderr":false,"headers":[],"x":430,"y":100,"wires":[[]]},{"id":"9f2b7781e2ed39ce","type":"inject","z":"d8eae825f6375435","d":true,"name":"Trigger","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":"5","topic":"","payload":"","payloadType":"date","x":200,"y":100,"wires":[["4cac35faa436999b"]]},{"id":"3dc89b4af1f94a6c","type":"http request","z":"d8eae825f6375435","name":"Get transaction","method":"GET","ret":"obj","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"bearer","senderr":false,"headers":[{"keyType":"Accept","keyValue":"","valueType":"other","valueValue":"application/vnd.api+json"},{"keyType":"Content-Type","keyValue":"","valueType":"other","valueValue":"application/json"}],"x":580,"y":300,"wires":[["e90a12e276f67767"]]},{"id":"4c6e826e776250b2","type":"function","z":"d8eae825f6375435","name":"process Webhook","func":"// Understand the webook.\n\n// check if the transaction id is defined... \nlet url = \"http://firefly:8080/api/v1/transactions/\" + msg.payload.content.id +\"/attachments\"\nflow.set(\"count\", 0)\n\n\nreturn { \n    \"payload\": url,\n    \"url\": url \n    }","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":370,"y":300,"wires":[["3dc89b4af1f94a6c"]]},{"id":"1a2c0b51c05bc935","type":"split","z":"d8eae825f6375435","name":"URl Split","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"topic","property":"payload","x":580,"y":360,"wires":[["8d837fbdbc264e78"]]},{"id":"f1e4b4ef98a7a4ac","type":"http request","z":"d8eae825f6375435","name":"Get attachment","method":"GET","ret":"bin","paytoqs":"ignore","url":"","tls":"","persist":false,"proxy":"","insecureHTTPParser":false,"authType":"bearer","senderr":false,"headers":[{"keyType":"Content-Type","keyValue":"","valueType":"other","valueValue":"application/octet-stream"}],"x":380,"y":420,"wires":[["3baa08b9d27430ed","711d6048cf70db2b"]]},{"id":"8d837fbdbc264e78","type":"function","z":"d8eae825f6375435","name":"Relabel","func":"var data = msg.payload\n\n\nreturn {\n  \"payload\": data,\n  \"url\": data.url\n};","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":740,"y":360,"wires":[["f1e4b4ef98a7a4ac","c60140bebe809f23"]]},{"id":"711d6048cf70db2b","type":"debug","z":"d8eae825f6375435","name":"attachment","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":590,"y":500,"wires":[]},{"id":"3baa08b9d27430ed","type":"function","z":"d8eae825f6375435","name":"Check Status Code","func":"var myCount = flow.get(\"count\");\nmyCount++\n\nif (msg.statusCode == \"200\") {\n  var filename = msg.headers[\"content-disposition\"].split(\"=\")[1]\n  filename = filename.replace(/['\"]+/g, '')\n  msg.headers = {\n    \"content-type\" : 'multipart/form-data',\n    \"authorization\" : \"token BBBBB\"\n  };\n  let databuffer = msg.payload;\n  msg.url=\"http://paperlessngx:8010/api/documents/post_document/\"\n  msg.payload = {\n      \"document\": {\n        \"value\": databuffer,\n        \"options\": {\n            \"filename\": filename\n        }\n      }\n  }\n  return [msg,null];\n} else {\n  if (myCount < 3) {\n    flow.set(\"count\", myCount)\n    return [null, msg];\n  }\n}\n","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":610,"y":420,"wires":[["7fb5c4f5b3b256bc","3b49833f267660d1"],["437faaa95487aebf"]],"outputLabels":["ok","error"]},{"id":"e90a12e276f67767","type":"delay","z":"d8eae825f6375435","name":"","pauseType":"delay","timeout":"30","timeoutUnits":"seconds","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":760,"y":300,"wires":[["1fc0753be7dc551b"]]},{"id":"437faaa95487aebf","type":"delay","z":"d8eae825f6375435","name":"","pauseType":"delay","timeout":"2","timeoutUnits":"minutes","rate":"1","nbRateUnits":"1","rateUnits":"second","randomFirst":"1","randomLast":"5","randomUnits":"seconds","drop":false,"allowrate":false,"outputs":1,"x":360,"y":500,"wires":[["f1e4b4ef98a7a4ac"]]},{"id":"7fb5c4f5b3b256bc","type":"debug","z":"d8eae825f6375435","name":"All output","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":800,"y":500,"wires":[]},{"id":"b06080671d9ea397","type":"debug","z":"d8eae825f6375435","name":"Sending Debug","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":1000,"y":500,"wires":[]},{"id":"fed3ed8b.c9707","type":"server","name":"Home Assistant","addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"","connectionDelay":false,"cacheJson":false,"heartbeat":false,"heartbeatInterval":"","statusSeparator":"","enableGlobalContextStore":false},{"id":"3e59278ed8264835","type":"ha-entity-config","server":"fed3ed8b.c9707","deviceConfig":"5496d8f6ed93bcd8","name":"new-switch","version":6,"entityType":"switch","haConfig":[{"property":"name","value":"new-switch"},{"property":"icon","value":""},{"property":"entity_picture","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":""}],"resend":false,"debugEnabled":false},{"id":"931b45abef410a9b","type":"ha-entity-config","server":"fed3ed8b.c9707","deviceConfig":"5496d8f6ed93bcd8","name":"update-switch","version":6,"entityType":"switch","haConfig":[{"property":"name","value":"update-switch"},{"property":"icon","value":""},{"property":"entity_picture","value":""},{"property":"entity_category","value":""},{"property":"device_class","value":""}],"resend":false,"debugEnabled":false},{"id":"5496d8f6ed93bcd8","type":"ha-device-config","name":"Nodered-Switch","hwVersion":"","manufacturer":"Node-RED","model":"","swVersion":""}]

Flow Overview

  • Webhook nodes:
    • One for new transactions (AAAAABBBBB)
    • One for updates (CCCCCDDDDD)
  • Attachment processing function:
    Extracts filenames and download URLs from Firefly III’s JSON response.
  • Loop & split:
    Splits the list of attachments so each is downloaded individually.
  • Retry logic:
    If an attachment download fails, the flow retries up to 3 times with a delay of 2 minutes. Your delay could depend on various different factors.
  • Paperless NGX upload:
    Sends the file as multipart/form-data with the correct filename.

Step 3 – Configuration Details

You’ll need to adjust:

  • Webhook URLs – in both the trigger and in Home Assistant
  • Firefly III API base URL – replace firefly:8080 with your server’s address
  • Paperless NGX API endpoint – replace paperless:8010
  • Paperless NGX API token – in the Check Status Code function node ("authorization": "token BBBBB")

Step 4 – Testing & Troubleshooting

Once the flow is deployed:

  1. Trigger a new transaction in Firefly III with an attachment.
  2. Check Node-RED’s debug sidebar for any errors.
  3. Confirm the document appears in Paperless NGX.

Common pitfalls:

  • Wrong webhook URL in Home Assistant
  • API token missing or incorrect
  • Firefly III or Paperless NGX running on different networks without proper access

Why This Setup Works for Me

This automation means that:

  • Every receipt is automatically archived the moment I log an expense in Firefly III.
  • I never have to manually download and re-upload PDFs.
  • My financial records and document archive are always in sync.

It’s a small quality-of-life improvement, but over time it saves a lot of clicks.


Final Thoughts

This project is a great example of how self-hosted tools can work together with a bit of automation glue. Node-RED’s flexibility inside Home Assistant makes it possible to bridge apps that were never designed to talk to each other.

If you’re already running Firefly III and Paperless NGX, this setup can bring them together in a really powerful way.


About the author

Tim Wilkes is a UK-based security architect with over 15 years of experience in electronics, Linux, and Unix systems administration. Since 2021, he's been designing secure systems for a telecom company while indulging his passions for programming, automation, and 3D printing. Tim shares his projects, tinkering adventures, and tech insights here - partly as a personal log, and partly in the hopes that others will find them useful.

Want to connect or follow along?

LinkedIn: [phpsytems]
Twitter / X: [@timmehwimmy]
Mastodon: [@timmehwimmy@infosec.exchange]


If you've found a post helpful, consider supporting the blog - it's a part-time passion that your support helps keep alive.

⚠️ Disclaimer

This post may contain affiliate links. If you choose to purchase through them, I may earn a small commission at no extra cost to you. I only recommend items and services I’ve personally read or used and found valuable.

As an Amazon Associate I earn from qualifying purchases.