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.

It listens for new or updated Firefly III transactions, fetches attachments, and sends them to Paperless NGX.
Here’s the high-level breakdown:
- Webhook triggers in Home Assistant – for new and updated Firefly III transactions.
- Retrieve attachments – Calls the Firefly III API to get the list of attachments for the transaction.
- Download each attachment – Uses HTTP requests to pull the file data.
- 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
)
- One for new transactions (
- 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 asmultipart/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:
- Trigger a new transaction in Firefly III with an attachment.
- Check Node-RED’s debug sidebar for any errors.
- 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.