Skylight Monero Wallet (MAGIC Grants), monero-lws, TX notifier

npub1nu78yt3grwq7jdp0twz56ral4ez6s2xx9zt96yjz8p89grvmzu5sdwndfa
hex
b6726bb72f575478c3dca29ffb7f6f12f284644102f0f013b80d154651e410d9nevent
nevent1qqstvuntkuh4w4rcc0w298lm0ah39u5yv3qs9u8szwuq692x28jppkgprpmhxue69uhhyetvv9ujuem4d36kwatvw5hx6mm9qgsf70rj9c5phq0fxsh4hp2dp7l6u3dg9rrz39jazfprsnj5pkd3w2gwym2kpnaddr
naddr1qq2ng5r0x3gkw3eefdfhj4ms2pe8gnr095kk2qgcwaehxw309aex2mrp0yhxwatvw4nh2mr49ekk7egzyz0ncu3w9qdcr6f59adc2ng0h7hyt2pgcc5fvhgjgguyu4qdnvtjjqcyqqq823cu7lg8nKind-30023 (Article)
Been running Skylight Monero wallet (by MAGIC Grants) pointed at my own monero-lws instance for a few days now.
Other wallets promise "auto sync" but anyone who's actually used them knows it rarely works. You open the app, stare at a progress bar depending on how long it's been. Sometimes it just hangs. Sometimes it silently fails.
Skylight + monero-lws eliminates this entirely. Your server scans the chain 24/7 in the background. When you open the wallet, your balance and full tx history are just there. Instantly. No sync. No waiting. No "synchronizing blocks 2847293/3630000."


The server also pushes webhook notifications on every incoming and outgoing tx the moment it hits a block. I have mine wired to Matrix through n8n. Real-time structured alerts with amount, block height, tx hash, and a link to the block explorer.
The whole stack: monerod (own node) + monero-lws (light wallet server by vtnerd) + Skylight (Android wallet by MAGIC Grants). All open source. All self-hosted. Your view key stays on your infrastructure.
It's alpha software so expect rough edges (exchange rate display is broken, filed the bug). But the core functionality of instant sync and webhook notifications already works better than anything else in the Monero ecosystem.
If you run your own node already, adding monero-lws takes one docker container. Skylight connects to it over Tailscale/Tor/clearnet. The documentation is sparse but the software works.
monero-lws: Admin API, Webhooks, and a Working XMR Transaction Notifier
practical findings we couldnt' find in documentation.
monero-lws by vtnerd powers the new Skylight wallet by MAGIC Grants, supports webhook notifications including zero-conf, and scans the chain continuously so your wallet opens instantly with no sync delay.
The documentation is currently lacking. The README tells you to run --help and figure it out. The admin REST API, webhook registration format, and payload structures are buried in C++ source code with no examples, no curl commands, no JSON schemas.
This article documents everything we reverse-engineered from the source code and live testing. Tested against monero-lws v1.0-alpha (commit fe3f4099), running in Docker.
Docker Compose Setup
monero-lws:
build: ./monero-lws
container_name: monero-lws
restart: unless-stopped
depends_on:
- monerod
ports:
- 8443:8443 # Main REST (wallet connections)
- 8444:8444 # Admin REST (account/webhook management)
volumes:
- /path/to/lws-data:/home/monero-lws/.bitmonero/light_wallet_server
command:
- monero-lws-daemon
- --daemon=tcp://monerod:18082
- --sub=tcp://monerod:18084
- --rest-server=http://0.0.0.0:8443
- --admin-rest-server=http://0.0.0.0:8444
- --confirm-external-bind
- --max-subaddresses=100000
- --disable-admin-auth
- --auto-accept-creation
- --exchange-rate-interval=15
Key flags
--admin-rest-servermust be set explicitly with its own port. Without it, the admin API doesn't exist.--disable-admin-authmakes the auth field optional. Required for local/Tailscale setups unless you want to deal with admin keys.--auto-accept-creationlets wallets register without manual admin approval.--exchange-rate-interval=15fetches fiat rates from cryptocompare every 15 minutes. Without it, Skylight shows permanent loading bars where USD values should be.--untrusted-daemondoes full PoW verification of every block. If you run your own local monerod, skip this flag. It re-verifies the entire chain from block 0 on every restart and will grind for hours/days before the REST servers start accepting requests.
HTTP not HTTPS on the REST server
Despite using port 8443, the rest-server runs http:// not https://. TLS requires --rest-ssl-key and --rest-ssl-certificate flags with PEM files. If you're connecting over Tailscale or localhost, HTTP is fine.
The Admin API
All admin endpoints live on the admin port (8444 in this example). Every single request requires a {"params": {...}} wrapper around the actual payload. This is documented nowhere. Without it you get 500 Internal Server Error with zero explanation in the response body.
Account Management
# List all accounts
curl -s -X POST http://127.0.0.1:8444/list_accounts \
-H 'Content-Type: application/json' \
-d '{}'
# List pending creation requests
curl -s -X POST http://127.0.0.1:8444/list_requests \
-H 'Content-Type: application/json' \
-d '{}'
Note: list_accounts and list_requests accept '{}' without the params wrapper. The webhook endpoints require it.
All Admin Endpoints (from rest_server.cpp)
/list_accounts/list_requests/add_account/accept_requests/reject_requests/modify_account_status/rescan/rollback/validate/webhook_add/webhook_delete/webhook_delete_uuid/webhook_list
Webhooks
Valid Webhook Types
From db/data.cpp, using hyphens not underscores:
tx-confirmation-- fires when an incoming transaction is confirmed in a blocktx-spend-- fires when an outgoing transaction is detectedtx-receive-- does NOT exist. Returns blank response, silently fails. Don't use it.
Registering a Webhook
# Incoming transactions (1 confirmation)
curl -s -X POST http://127.0.0.1:8444/webhook_add \
-H 'Content-Type: application/json' \
-d '{
"params": {
"url": "https://your-webhook-url/endpoint",
"type": "tx-confirmation",
"address": "YOUR_MONERO_ADDRESS",
"confirmations": 1
}
}'
# Outgoing transactions
curl -s -X POST http://127.0.0.1:8444/webhook_add \
-H 'Content-Type: application/json' \
-d '{
"params": {
"url": "https://your-webhook-url/endpoint",
"type": "tx-spend",
"address": "YOUR_MONERO_ADDRESS"
}
}'
Successful registration returns:
{
"payment_id": "0000000000000000",
"event_id": "0b5a32746141445a814a01213f9b8f81",
"token": "",
"confirmations": 1,
"url": "https://your-webhook-url/endpoint"
}
Listing and Deleting Webhooks
# List all registered webhooks
curl -s -X POST http://127.0.0.1:8444/webhook_list \
-H 'Content-Type: application/json' \
-d '{}' | python3 -m json.tool
# Delete webhooks by address
curl -s -X POST http://127.0.0.1:8444/webhook_delete \
-H 'Content-Type: application/json' \
-d '{"params": {"addresses": ["YOUR_MONERO_ADDRESS"]}}'
Real Webhook Payload Structure
This is what monero-lws actually POSTs to your webhook URL when a transaction is detected. Captured from a live tx-confirmation event:
{
"event": "tx-confirmation",
"payment_id": "0000000000000000",
"token": "",
"confirmations": 1,
"event_id": "0b5a32746141445a814a01213f9b8f81",
"tx_info": {
"id": {
"high": 0,
"low": 151142680
},
"block": 3630390,
"index": 0,
"amount": 1350656030000,
"timestamp": 1773535788,
"tx_hash": "3bf9257c40798de8114392fd7bc469df671cfb22ef6656955c81c46d72bafc47",
"tx_prefix_hash": "2b1e331ef2b68e9c5533b090f5b04831f4c1291f23eb7b476d98e9ec524e3436",
"tx_public": "36c2b90f3a30246235c649313a592dd869eff753ef850ccbb71ed9d71ab1facf",
"rct_mask": "7d71dbfdeb340758c9e695d25da83ea706d9928a99ca40963b9e41dbe9ef1a02",
"payment_id": "0000000000000000",
"unlock_time": 0,
"mixin_count": 15,
"coinbase": false,
"fee": 177280000,
"recipient": {
"maj_i": 0,
"min_i": 0
},
"pub": "f10ae4c18f28c27b6e8f9b91a1b3fcab1136c53eef4e1530b08ce12a10b1a526"
}
}
tx-spend has a completely different structure
The tx-spend payload is NOT the same shape as tx-confirmation. Captured from a live tx-spend event:
{
"event": "tx-spend",
"token": "",
"event_id": "75cb712e84564153a20d7476b45cb421",
"tx_info": {
"input": {
"height": 3630435,
"tx_hash": "ef6ebb5dd337afe038681a152ae0d090b83b710de524ef208ef58cae517e70a3",
"image": "c2754e785a57807d9c935e270245e86712a27d5f813b792627474050ceb2c0d3",
"source": {
"high": 0,
"low": 150165702
},
"timestamp": 1773543880,
"unlock_time": 0,
"mixin_count": 15,
"sender": {
"maj_i": 0,
"min_i": 0
}
},
"source": {
"id": {
"high": 0,
"low": 150165702
},
"amount": 4690361647134,
"mixin": 15,
"index": 1,
"tx_public": "bb4dc5dfbfd297dfa759cd7198b11a2e40c4dde566c2813c350326e6de348eac"
}
}
}
Key differences from tx-confirmation:
- Amount is at
tx_info.source.amount, nottx_info.amount - Tx hash is at
tx_info.input.tx_hash, nottx_info.tx_hash - Block height is at
tx_info.input.height, nottx_info.block - Timestamp is at
tx_info.input.timestamp, nottx_info.timestamp - No
feefield in the spend payload - No
payment_idat the top level - Includes
image(key image) andsource(the output being spent)
amount is in piconero (1 XMR = 1,000,000,000,000 atomic units). Divide by 1e12 to get XMR.
Important: multiple fires per transaction
Monero transactions create multiple outputs. If you send XMR, the change output returns to your address, triggering a second tx-confirmation webhook for the same tx_hash. Both outputs have different amount and index values but share the same tx_hash. If someone sends you XMR, you only get one fire since the change goes back to their wallet.
A Real-Time Transaction Notifier with n8n
We wired monero-lws webhooks to Matrix/Element notifications through n8n. The full pipeline: monero-lws detects tx in block scan, POSTs webhook to n8n over Tailscale, n8n parses the payload, suppresses change outputs, sends formatted notification to a Matrix room.
n8n Code Node (handles both event types, with change output suppression)
When you send XMR, monero-lws fires both a tx-spend (outgoing) and a tx-confirmation (change returning). This code handles the different payload structures and suppresses the change confirmation:
const staticData = $getWorkflowStaticData('global');
if (!staticData.spentTxs) staticData.spentTxs = {};
const body = $input.first().json.body;
const event = body.event;
let amount, txHash, block, timestamp, fee;
if (event === 'tx-spend') {
amount = body.tx_info.source.amount / 1e12;
txHash = body.tx_info.input.tx_hash;
block = body.tx_info.input.height;
timestamp = body.tx_info.input.timestamp;
fee = 0;
staticData.spentTxs[txHash] = Date.now();
} else {
// tx-confirmation
txHash = body.tx_info.tx_hash;
if (staticData.spentTxs[txHash]) {
return [];
}
amount = body.tx_info.amount / 1e12;
block = body.tx_info.block;
timestamp = body.tx_info.timestamp;
fee = body.tx_info.fee / 1e12;
}
// Clean old entries
const keys = Object.keys(staticData.spentTxs);
if (keys.length > 200) {
const sorted = keys.sort((a, b) => staticData.spentTxs[a] - staticData.spentTxs[b]);
for (let i = 0; i < keys.length - 200; i++) {
delete staticData.spentTxs[sorted[i]];
}
}
const ts = new Date(timestamp * 1000);
const timeStr = ts.toLocaleTimeString('en-US', {
hour: '2-digit', minute: '2-digit', hour12: true
});
const dateStr = ts.toLocaleDateString('en-GB', {
day: '2-digit', month: '2-digit', year: '2-digit'
});
return [{
json: {
tx_type: event === 'tx-confirmation' ? 'in' : 'out',
readable_amount: amount.toFixed(4),
event_time: `${timeStr} | ${dateStr}`,
tx_hash: txHash,
confirmations: body.confirmations || 1,
block: block,
fee: fee.toFixed(6),
coinbase: event === 'tx-confirmation' ? body.tx_info.coinbase : false
}
}];
Matrix/Element Notification Template
{{ $json.tx_type == 'in' ? '+' : '-' }}{{ parseFloat($json.readable_amount).toFixed(4) }} XMR
Block {{ $json.block }} | {{ $json.confirmations }} conf
{{ $json.event_time }}
{{ $json.tx_hash.substring(0,8) }}...{{ $json.tx_hash.slice(-8) }}
xmrchain.net/tx/{{ $json.tx_hash }}
Produces notifications like:
+4.8399 XMR
Block 3630395 | 1 conf
01:00 AM | 15/03/26
cf474c9d...267b4d9c
xmrchain.net/tx/cf474c9d30e81ab5d5a83d25add18e691fdb58ec50bdd904b7c5146a267b4d9c
Ad Hoc Findings and Rough Edges (v1.0-alpha, commit fe3f4099)
This is alpha software. We ran into several issues over the course of setting things up. Some of this might be incomplete or specific to our environment. Sharing it in case it saves someone time.
LMDB Scanner Crashes (MDB_NOTFOUND)
We experienced scanner crashes with this error on multiple occasions:
lmdb error (storage.cpp:3122): MDB_NOTFOUND: No matching key/data pair found
Scanner shutdown with error Failed to update accounts on disk (thrown at scanner.cpp:1540)
Once this happens, the container crash-loops at the same block height on every restart. The only fix we found is restoring the LMDB data directory from a backup.
We saw this in two different situations:
Instance 1: Scanner crashed at block 3,492,827 during a large rescan, roughly 31,000 blocks after webhooks were registered. This might be related to GitHub issue #225 on the monero-lws repo, which reports the same error during rescans without webhooks.
Instance 2: Scanner crashed at block 3,630,424, roughly 30 blocks after registering webhooks via the admin API. Restored from a pre-webhook backup, scanner went past the same block without any issue. Registered webhooks again, crash happened again. Restored again, sailed right past. This happened twice in one night.
Instance 3: Same night, restored from backup again, registered webhooks again. Scanner crashed at block 3,630,440, just 5 blocks after registration. Restored without webhooks, scanner continued fine.
Three crashes, three different block gaps (31K blocks, ~30 blocks, 5 blocks). It's not a specific block causing the issue. Every time we removed webhooks and restored, the scanner processed past the crash point without problems. Every time we added webhooks back, it crashed again. We suspect that webhook registration corrupts something in the LMDB state that the scanner can't handle. But we haven't read the full source, so take this with a grain of salt.
⚠️ Recommendation: Always Back Up Before Registering Webhooks ⚠️
Let your scan reach the chain tip first. Back up the LMDB data directory. Then try adding webhooks. If things go sideways, restore and you're back to a working state in seconds. The LMDB directory is small (around 264MB for one account w/ couple years of heavy Monero TX history, fully synced). A backup saves hours.
docker compose stop monero-lws
docker run --rm \
-v /path/to/lws-data:/data \
-v /path/to/backups:/dest \
alpine cp -r /data /dest/monero-lws-backup-$(date +%Y%m%d)
docker compose up -d monero-lws
Skylight Wallet Issues
Exchange rate display broken. Server returns rates correctly in logs but the wallet shows loading bars where fiat values should be. Filed as issue #102 on MAGICGrants/skylight-wallet. Confirmed server-side working, client-side rendering bug.
Red warning triangle. Appears below the balance, related to the exchange rate issue. Filed as #100 on the same repo.
REST Server Quirks
Server doesn't respond during chain verification. If --untrusted-daemon is enabled, both REST servers (main and admin) refuse connections until PoW verification reaches the chain tip. The TCP connection establishes but immediately resets. Remove the flag if you run your own monerod.
Sparse error messages. 500 errors return empty bodies. The only way to debug is docker logs. Set --log-level=4 for full debug output when troubleshooting. Common errors: "Schema missing required field key" (missing params wrapper), "bad method" (wrong HTTP verb, usually GET instead of POST).
PARSE URI regex warnings. You'll see [PARSE URI] regex not matched for uri in the logs frequently. This fires for webhook URLs and server bind addresses. As far as we can tell, it's a non-fatal fallback in the URL parser. URLs still get stored and used correctly.
Tips
- Let the initial scan complete fully before doing anything else. Don't add accounts, webhooks, or change config mid-scan.
- Back up the LMDB data directory after the scan reaches the chain tip. This is your clean restore point for everything that follows.
- Consider using bind mounts for easy database recovery, not Docker named volumes, for the LMDB data. Named volumes are buried in
/var/lib/docker/volumes/. - The
--untrusted-daemonflag re-verifies the entire chain from block 0 on every restart. Only use it if your monerod is a remote untrusted node. If you run your own local monerod, skip it. - Webhooks persist across container restarts. They're stored in the LMDB database, which is why they can affect scanner stability.
- The admin port should NOT be exposed publicly. Keep it on localhost or behind Tailscale.
- monero-lws requires monerod with ZMQ enabled (
--zmq-puband--zmq-rpc-bind-port). Some popular Docker images ship with--no-zmqas a default flag. If monero-lws can't connect to your monerod, check that ZMQ isn't disabled. --log-level=4gives full debug output. Useful when troubleshooting webhook or scanner issues. Default is 1.
Support
MAGIC Grants is the nonprofit behind Skylight wallet and the funding source for vtnerd's monero-lws development. They don't monetize the wallet. From their website:
"How do you make money from this? We don't. There is currently no monetization feature. Please consider making a donation (which may qualify for a tax deduction) on our campaign website."
If this guide helped you, consider donating: https://donate.magicgrants.org/
*Documented by SoulReaver with Claude, March 2026. This is a field guide, not official documentation. Some details may be incomplete or specific to our setup.
#monero #xmr #monerolws #skylight #n8n #magicgrants
Raw JSON
{
"kind": 30023,
"id": "b6726bb72f575478c3dca29ffb7f6f12f284644102f0f013b80d154651e410d9",
"pubkey": "9f3c722e281b81e9342f5b854d0fbfae45a828c628965d1242384e540d9b1729",
"created_at": 1773547842,
"tags": [
[
"client",
"Yakihonne",
"31990:20986fb83e775d96d188ca5c9df10ce6d613e0eb7e5768a0f0b12b37cdac21b3:1700732875747"
],
[
"published_at",
"1773547491"
],
[
"d",
"4Po4QgG9KSyWpPrtLo--e"
],
[
"image",
"https://skylight.magicgrants.org/img/logos/logo_small.svg"
],
[
"title",
"Skylight Monero Wallet (MAGIC Grants), monero-lws, TX notifier"
],
[
"summary",
"Skylight Monero Wallet (MAGIC Grants), monero-lws, TX notifier"
],
[
"t",
"monero"
],
[
"t",
"xmr"
],
[
"t",
"monerolws"
],
[
"t",
"skylight"
],
[
"t",
"n8n"
],
[
"t",
"magicgrants"
]
],
"content": "Been running Skylight Monero wallet (by MAGIC Grants) pointed at my own monero-lws instance for a few days now.\n\nOther wallets promise \"auto sync\" but anyone who's actually used them knows it rarely works. You open the app, stare at a progress bar depending on how long it's been. Sometimes it just hangs. Sometimes it silently fails.\n\nSkylight + monero-lws eliminates this entirely. Your server scans the chain 24/7 in the background. When you open the wallet, your balance and full tx history are just there. Instantly. No sync. No waiting. No \"synchronizing blocks 2847293/3630000.\"\n\n\n\n\n\nThe server also pushes webhook notifications on every incoming and outgoing tx the moment it hits a block. I have mine wired to Matrix through n8n. Real-time structured alerts with amount, block height, tx hash, and a link to the block explorer.\n\nThe whole stack: monerod (own node) + monero-lws (light wallet server by vtnerd) + Skylight (Android wallet by MAGIC Grants). All open source. All self-hosted. Your view key stays on your infrastructure.\n\nIt's alpha software so expect rough edges (exchange rate display is broken, filed the bug). But the core functionality of instant sync and webhook notifications already works better than anything else in the Monero ecosystem.\n\nIf you run your own node already, adding monero-lws takes one docker container. Skylight connects to it over Tailscale/Tor/clearnet. The documentation is sparse but the software works.\n\n---\n\n# monero-lws: Admin API, Webhooks, and a Working XMR Transaction Notifier\n\n*practical findings we couldnt' find in documentation.*\n\nmonero-lws by vtnerd powers the new Skylight wallet by MAGIC Grants, supports webhook notifications including zero-conf, and scans the chain continuously so your wallet opens instantly with no sync delay.\n\nThe documentation is currently lacking. The README tells you to run `--help` and figure it out. The admin REST API, webhook registration format, and payload structures are buried in C++ source code with no examples, no curl commands, no JSON schemas.\n\nThis article documents everything we reverse-engineered from the source code and live testing. Tested against monero-lws v1.0-alpha (commit fe3f4099), running in Docker.\n\n---\n\n## Docker Compose Setup\n\n```yaml\nmonero-lws:\n build: ./monero-lws\n container_name: monero-lws\n restart: unless-stopped\n depends_on:\n - monerod\n ports:\n - 8443:8443 # Main REST (wallet connections)\n - 8444:8444 # Admin REST (account/webhook management)\n volumes:\n - /path/to/lws-data:/home/monero-lws/.bitmonero/light_wallet_server\n command:\n - monero-lws-daemon\n - --daemon=tcp://monerod:18082\n - --sub=tcp://monerod:18084\n - --rest-server=http://0.0.0.0:8443\n - --admin-rest-server=http://0.0.0.0:8444\n - --confirm-external-bind\n - --max-subaddresses=100000\n - --disable-admin-auth\n - --auto-accept-creation\n - --exchange-rate-interval=15\n```\n\n### Key flags\n\n- `--admin-rest-server` must be set explicitly with its own port. Without it, the admin API doesn't exist.\n- `--disable-admin-auth` makes the auth field optional. Required for local/Tailscale setups unless you want to deal with admin keys.\n- `--auto-accept-creation` lets wallets register without manual admin approval.\n- `--exchange-rate-interval=15` fetches fiat rates from cryptocompare every 15 minutes. Without it, Skylight shows permanent loading bars where USD values should be.\n- `--untrusted-daemon` does full PoW verification of every block. If you run your own local monerod, skip this flag. It re-verifies the entire chain from block 0 on every restart and will grind for hours/days before the REST servers start accepting requests.\n\n### HTTP not HTTPS on the REST server\n\nDespite using port 8443, the rest-server runs `http://` not `https://`. TLS requires `--rest-ssl-key` and `--rest-ssl-certificate` flags with PEM files. If you're connecting over Tailscale or localhost, HTTP is fine.\n\n---\n\n## The Admin API\n\nAll admin endpoints live on the admin port (8444 in this example). Every single request requires a `{\"params\": {...}}` wrapper around the actual payload. This is documented nowhere. Without it you get `500 Internal Server Error` with zero explanation in the response body.\n\n### Account Management\n\n```bash\n# List all accounts\ncurl -s -X POST http://127.0.0.1:8444/list_accounts \\\n -H 'Content-Type: application/json' \\\n -d '{}'\n\n# List pending creation requests\ncurl -s -X POST http://127.0.0.1:8444/list_requests \\\n -H 'Content-Type: application/json' \\\n -d '{}'\n```\n\nNote: `list_accounts` and `list_requests` accept `'{}'` without the params wrapper. The webhook endpoints require it.\n\n### All Admin Endpoints (from rest_server.cpp)\n\n- `/list_accounts`\n- `/list_requests`\n- `/add_account`\n- `/accept_requests`\n- `/reject_requests`\n- `/modify_account_status`\n- `/rescan`\n- `/rollback`\n- `/validate`\n- `/webhook_add`\n- `/webhook_delete`\n- `/webhook_delete_uuid`\n- `/webhook_list`\n\n---\n\n## Webhooks\n\n### Valid Webhook Types\n\nFrom `db/data.cpp`, using hyphens not underscores:\n\n- `tx-confirmation` -- fires when an incoming transaction is confirmed in a block\n- `tx-spend` -- fires when an outgoing transaction is detected\n- `tx-receive` -- does NOT exist. Returns blank response, silently fails. Don't use it.\n\n### Registering a Webhook\n\n```bash\n# Incoming transactions (1 confirmation)\ncurl -s -X POST http://127.0.0.1:8444/webhook_add \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"params\": {\n \"url\": \"https://your-webhook-url/endpoint\",\n \"type\": \"tx-confirmation\",\n \"address\": \"YOUR_MONERO_ADDRESS\",\n \"confirmations\": 1\n }\n }'\n\n# Outgoing transactions\ncurl -s -X POST http://127.0.0.1:8444/webhook_add \\\n -H 'Content-Type: application/json' \\\n -d '{\n \"params\": {\n \"url\": \"https://your-webhook-url/endpoint\",\n \"type\": \"tx-spend\",\n \"address\": \"YOUR_MONERO_ADDRESS\"\n }\n }'\n```\n\nSuccessful registration returns:\n\n```json\n{\n \"payment_id\": \"0000000000000000\",\n \"event_id\": \"0b5a32746141445a814a01213f9b8f81\",\n \"token\": \"\",\n \"confirmations\": 1,\n \"url\": \"https://your-webhook-url/endpoint\"\n}\n```\n\n### Listing and Deleting Webhooks\n\n```bash\n# List all registered webhooks\ncurl -s -X POST http://127.0.0.1:8444/webhook_list \\\n -H 'Content-Type: application/json' \\\n -d '{}' | python3 -m json.tool\n\n# Delete webhooks by address\ncurl -s -X POST http://127.0.0.1:8444/webhook_delete \\\n -H 'Content-Type: application/json' \\\n -d '{\"params\": {\"addresses\": [\"YOUR_MONERO_ADDRESS\"]}}'\n```\n\n---\n\n## Real Webhook Payload Structure\n\nThis is what monero-lws actually POSTs to your webhook URL when a transaction is detected. Captured from a live `tx-confirmation` event:\n\n```json\n{\n \"event\": \"tx-confirmation\",\n \"payment_id\": \"0000000000000000\",\n \"token\": \"\",\n \"confirmations\": 1,\n \"event_id\": \"0b5a32746141445a814a01213f9b8f81\",\n \"tx_info\": {\n \"id\": {\n \"high\": 0,\n \"low\": 151142680\n },\n \"block\": 3630390,\n \"index\": 0,\n \"amount\": 1350656030000,\n \"timestamp\": 1773535788,\n \"tx_hash\": \"3bf9257c40798de8114392fd7bc469df671cfb22ef6656955c81c46d72bafc47\",\n \"tx_prefix_hash\": \"2b1e331ef2b68e9c5533b090f5b04831f4c1291f23eb7b476d98e9ec524e3436\",\n \"tx_public\": \"36c2b90f3a30246235c649313a592dd869eff753ef850ccbb71ed9d71ab1facf\",\n \"rct_mask\": \"7d71dbfdeb340758c9e695d25da83ea706d9928a99ca40963b9e41dbe9ef1a02\",\n \"payment_id\": \"0000000000000000\",\n \"unlock_time\": 0,\n \"mixin_count\": 15,\n \"coinbase\": false,\n \"fee\": 177280000,\n \"recipient\": {\n \"maj_i\": 0,\n \"min_i\": 0\n },\n \"pub\": \"f10ae4c18f28c27b6e8f9b91a1b3fcab1136c53eef4e1530b08ce12a10b1a526\"\n }\n}\n```\n\n### tx-spend has a completely different structure\n\nThe `tx-spend` payload is NOT the same shape as `tx-confirmation`. Captured from a live `tx-spend` event:\n\n```json\n{\n \"event\": \"tx-spend\",\n \"token\": \"\",\n \"event_id\": \"75cb712e84564153a20d7476b45cb421\",\n \"tx_info\": {\n \"input\": {\n \"height\": 3630435,\n \"tx_hash\": \"ef6ebb5dd337afe038681a152ae0d090b83b710de524ef208ef58cae517e70a3\",\n \"image\": \"c2754e785a57807d9c935e270245e86712a27d5f813b792627474050ceb2c0d3\",\n \"source\": {\n \"high\": 0,\n \"low\": 150165702\n },\n \"timestamp\": 1773543880,\n \"unlock_time\": 0,\n \"mixin_count\": 15,\n \"sender\": {\n \"maj_i\": 0,\n \"min_i\": 0\n }\n },\n \"source\": {\n \"id\": {\n \"high\": 0,\n \"low\": 150165702\n },\n \"amount\": 4690361647134,\n \"mixin\": 15,\n \"index\": 1,\n \"tx_public\": \"bb4dc5dfbfd297dfa759cd7198b11a2e40c4dde566c2813c350326e6de348eac\"\n }\n }\n}\n```\n\nKey differences from `tx-confirmation`:\n\n- Amount is at `tx_info.source.amount`, not `tx_info.amount`\n- Tx hash is at `tx_info.input.tx_hash`, not `tx_info.tx_hash`\n- Block height is at `tx_info.input.height`, not `tx_info.block`\n- Timestamp is at `tx_info.input.timestamp`, not `tx_info.timestamp`\n- No `fee` field in the spend payload\n- No `payment_id` at the top level\n- Includes `image` (key image) and `source` (the output being spent)\n\n`amount` is in piconero (1 XMR = 1,000,000,000,000 atomic units). Divide by 1e12 to get XMR.\n\n### Important: multiple fires per transaction\n\nMonero transactions create multiple outputs. If you send XMR, the change output returns to your address, triggering a second `tx-confirmation` webhook for the same `tx_hash`. Both outputs have different `amount` and `index` values but share the same `tx_hash`. If someone sends you XMR, you only get one fire since the change goes back to their wallet.\n\n---\n\n## A Real-Time Transaction Notifier with n8n\n\nWe wired monero-lws webhooks to Matrix/Element notifications through n8n. The full pipeline: monero-lws detects tx in block scan, POSTs webhook to n8n over Tailscale, n8n parses the payload, suppresses change outputs, sends formatted notification to a Matrix room.\n\n### n8n Code Node (handles both event types, with change output suppression)\n\nWhen you send XMR, monero-lws fires both a `tx-spend` (outgoing) and a `tx-confirmation` (change returning). This code handles the different payload structures and suppresses the change confirmation:\n\n```javascript\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.spentTxs) staticData.spentTxs = {};\n\nconst body = $input.first().json.body;\nconst event = body.event;\n\nlet amount, txHash, block, timestamp, fee;\n\nif (event === 'tx-spend') {\n amount = body.tx_info.source.amount / 1e12;\n txHash = body.tx_info.input.tx_hash;\n block = body.tx_info.input.height;\n timestamp = body.tx_info.input.timestamp;\n fee = 0;\n staticData.spentTxs[txHash] = Date.now();\n} else {\n // tx-confirmation\n txHash = body.tx_info.tx_hash;\n if (staticData.spentTxs[txHash]) {\n return [];\n }\n amount = body.tx_info.amount / 1e12;\n block = body.tx_info.block;\n timestamp = body.tx_info.timestamp;\n fee = body.tx_info.fee / 1e12;\n}\n\n// Clean old entries\nconst keys = Object.keys(staticData.spentTxs);\nif (keys.length \u003e 200) {\n const sorted = keys.sort((a, b) =\u003e staticData.spentTxs[a] - staticData.spentTxs[b]);\n for (let i = 0; i \u003c keys.length - 200; i++) {\n delete staticData.spentTxs[sorted[i]];\n }\n}\n\nconst ts = new Date(timestamp * 1000);\nconst timeStr = ts.toLocaleTimeString('en-US', {\n hour: '2-digit', minute: '2-digit', hour12: true\n});\nconst dateStr = ts.toLocaleDateString('en-GB', {\n day: '2-digit', month: '2-digit', year: '2-digit'\n});\n\nreturn [{\n json: {\n tx_type: event === 'tx-confirmation' ? 'in' : 'out',\n readable_amount: amount.toFixed(4),\n event_time: `${timeStr} | ${dateStr}`,\n tx_hash: txHash,\n confirmations: body.confirmations || 1,\n block: block,\n fee: fee.toFixed(6),\n coinbase: event === 'tx-confirmation' ? body.tx_info.coinbase : false\n }\n}];\n```\n\n### Matrix/Element Notification Template\n\n```\n{{ $json.tx_type == 'in' ? '+' : '-' }}{{ parseFloat($json.readable_amount).toFixed(4) }} XMR\nBlock {{ $json.block }} | {{ $json.confirmations }} conf\n{{ $json.event_time }}\n{{ $json.tx_hash.substring(0,8) }}...{{ $json.tx_hash.slice(-8) }}\nxmrchain.net/tx/{{ $json.tx_hash }}\n```\n\nProduces notifications like:\n\n```\n+4.8399 XMR\nBlock 3630395 | 1 conf\n01:00 AM | 15/03/26\ncf474c9d...267b4d9c\nxmrchain.net/tx/cf474c9d30e81ab5d5a83d25add18e691fdb58ec50bdd904b7c5146a267b4d9c\n```\n\n---\n\n## Ad Hoc Findings and Rough Edges (v1.0-alpha, commit fe3f4099)\n\nThis is alpha software. We ran into several issues over the course of setting things up. Some of this might be incomplete or specific to our environment. Sharing it in case it saves someone time.\n\n### LMDB Scanner Crashes (MDB_NOTFOUND)\n\nWe experienced scanner crashes with this error on multiple occasions:\n\n```\nlmdb error (storage.cpp:3122): MDB_NOTFOUND: No matching key/data pair found\nScanner shutdown with error Failed to update accounts on disk (thrown at scanner.cpp:1540)\n```\n\nOnce this happens, the container crash-loops at the same block height on every restart. The only fix we found is restoring the LMDB data directory from a backup.\n\nWe saw this in two different situations:\n\n**Instance 1:** Scanner crashed at block 3,492,827 during a large rescan, roughly 31,000 blocks after webhooks were registered. This might be related to GitHub issue #225 on the monero-lws repo, which reports the same error during rescans without webhooks.\n\n**Instance 2:** Scanner crashed at block 3,630,424, roughly 30 blocks after registering webhooks via the admin API. Restored from a pre-webhook backup, scanner went past the same block without any issue. Registered webhooks again, crash happened again. Restored again, sailed right past. This happened twice in one night.\n\n**Instance 3:** Same night, restored from backup again, registered webhooks again. Scanner crashed at block 3,630,440, just 5 blocks after registration. Restored without webhooks, scanner continued fine.\n\nThree crashes, three different block gaps (31K blocks, ~30 blocks, 5 blocks). It's not a specific block causing the issue. Every time we removed webhooks and restored, the scanner processed past the crash point without problems. Every time we added webhooks back, it crashed again. We suspect that webhook registration corrupts something in the LMDB state that the scanner can't handle. But we haven't read the full source, so take this with a grain of salt.\n\n### ⚠️ Recommendation: Always Back Up Before Registering Webhooks ⚠️\n\nLet your scan reach the chain tip first. Back up the LMDB data directory. Then try adding webhooks. If things go sideways, restore and you're back to a working state in seconds. The LMDB directory is small (around 264MB for one account w/ couple years of heavy Monero TX history, fully synced). A backup saves hours.\n\n```bash\ndocker compose stop monero-lws\ndocker run --rm \\\n -v /path/to/lws-data:/data \\\n -v /path/to/backups:/dest \\\n alpine cp -r /data /dest/monero-lws-backup-$(date +%Y%m%d)\ndocker compose up -d monero-lws\n```\n\n### Skylight Wallet Issues\n\n**Exchange rate display broken.** Server returns rates correctly in logs but the wallet shows loading bars where fiat values should be. Filed as issue #102 on MAGICGrants/skylight-wallet. Confirmed server-side working, client-side rendering bug.\n\n**Red warning triangle.** Appears below the balance, related to the exchange rate issue. Filed as #100 on the same repo.\n\n### REST Server Quirks\n\n**Server doesn't respond during chain verification.** If `--untrusted-daemon` is enabled, both REST servers (main and admin) refuse connections until PoW verification reaches the chain tip. The TCP connection establishes but immediately resets. Remove the flag if you run your own monerod.\n\n**Sparse error messages.** 500 errors return empty bodies. The only way to debug is `docker logs`. Set `--log-level=4` for full debug output when troubleshooting. Common errors: \"Schema missing required field key\" (missing `params` wrapper), \"bad method\" (wrong HTTP verb, usually GET instead of POST).\n\n**PARSE URI regex warnings.** You'll see `[PARSE URI] regex not matched for uri` in the logs frequently. This fires for webhook URLs and server bind addresses. As far as we can tell, it's a non-fatal fallback in the URL parser. URLs still get stored and used correctly.\n\n---\n\n## Tips\n\n- Let the initial scan complete fully before doing anything else. Don't add accounts, webhooks, or change config mid-scan.\n- Back up the LMDB data directory after the scan reaches the chain tip. This is your clean restore point for everything that follows.\n- Consider using bind mounts for easy database recovery, not Docker named volumes, for the LMDB data. Named volumes are buried in `/var/lib/docker/volumes/`.\n- The `--untrusted-daemon` flag re-verifies the entire chain from block 0 on every restart. Only use it if your monerod is a remote untrusted node. If you run your own local monerod, skip it.\n- Webhooks persist across container restarts. They're stored in the LMDB database, which is why they can affect scanner stability.\n- The admin port should NOT be exposed publicly. Keep it on localhost or behind Tailscale.\n- monero-lws requires monerod with ZMQ enabled (`--zmq-pub` and `--zmq-rpc-bind-port`). Some popular Docker images ship with `--no-zmq` as a default flag. If monero-lws can't connect to your monerod, check that ZMQ isn't disabled.\n- `--log-level=4` gives full debug output. Useful when troubleshooting webhook or scanner issues. Default is 1.\n\n---\n\n## Support\n\nMAGIC Grants is the nonprofit behind Skylight wallet and the funding source for vtnerd's monero-lws development. They don't monetize the wallet. From their website:\n\n*\"How do you make money from this? We don't. There is currently no monetization feature. Please consider making a donation (which may qualify for a tax deduction) on our campaign website.\"*\n\nIf this guide helped you, consider donating: https://donate.magicgrants.org/\n\n**Documented by SoulReaver with Claude, March 2026. This is a field guide, not official documentation. Some details may be incomplete or specific to our setup.*\n\n#monero #xmr #monerolws #skylight #n8n #magicgrants",
"sig": "185a20af93646b0d27aeb30beb6f644ee78e3fd4525a6f616de43d25313d85b111c92a08c99024886d13ae3cdc0964e4cb5a2485655db173a4dd8ad18db931d4"
}