Automated bank synchronization service for Actual Budget with multi-server support, health monitoring, and comprehensive error handling
- About
- Features
- Quick Start
- Installation
- Configuration
- Usage
- Docker Deployment
- Monitoring & Observability
- Notifications
- Missing-Payment Alerts
- Testing
- Security
- Troubleshooting
- Documentation
- Contributing
- License
- Acknowledgments
- Sponsor
Actual-sync is a production-ready Node.js service that automates bank transaction synchronization for Actual Budget servers. Built for reliability and ease of use, it eliminates the need for manual sync operations and ensures your financial data stays current across multiple budget instances.
- Automates scheduled bank synchronization using cron expressions
- Manages multiple Actual Budget servers from a single service
- Handles network failures, rate limits, and API errors gracefully
- Monitors sync health with HTTP endpoints and Prometheus metrics
- Notifies you of sync results via Telegram, email, Slack, Discord, ntfy, or generic webhooks
- Tracks complete sync history in SQLite with CLI query tools
Manually syncing bank transactions is tedious and error-prone. Actual-sync runs unattended in Docker or as a system service, keeping your budgets up-to-date with zero intervention. Perfect for personal finance enthusiasts, multi-user households, and anyone running self-hosted Actual Budget instances.
Built for production with enforced test coverage (see the live tests and coverage badges above), comprehensive error handling, structured logging, and enterprise-grade monitoring capabilities.
Modern Tabbed Interface with comprehensive monitoring and management:
📊 View More Screenshots
Overview Tab - 2-column layout with service health, server list, recent activity, and live logs:
Analytics Tab - All-time statistics with interactive charts:
History Tab - Searchable sync history with filters:
Settings Tab - Date format preferences and data management:
Multi-Server Support - Manage 6+ budget instances with encryption badges:
Error Handling - Clear visibility into partial failures and sync issues:
- ✅ Multi-Server Support - Manage unlimited Actual Budget instances with independent configurations
- ✅ Encrypted Budget Support - Full support for end-to-end encrypted (E2EE) budget files
- ✅ Flexible Scheduling - Global and per-server cron schedules with timezone support
- ✅ Intelligent Retry Logic - Exponential backoff with rate limit detection and handling
- ✅ Account Discovery - List all accessible bank accounts across servers
- ✅ Manual Sync Trigger - On-demand synchronization via CLI, dashboard or Telegram bot; inside the running service syncs run one at a time, so a manual sync waits for any sync already running (do not run
npm run syncwhile the service is running: it is a separate process) - ✅ Configuration Validation - Business rules (required fields, ranges, unique servers) and JSON-schema rules (types, ranges, formats, patterns, enums) both hard-fail at startup with clear, aggregated messages; unknown/typo'd keys warn.
CONFIG_STRICT=falsedowngrades the schema hard-fails to warnings during a migration
- ✅ Modern Web Dashboard - Tabbed interface with Overview, Analytics, History, and Settings
- ✅ 2-Column Overview Layout - Service health, server list, recent activity, and live logs
- ✅ Sync Status Badges - Color-coded badges showing success/partial/failure for each server
- ✅ Security Headers - The dashboard is served with a strict Content-Security-Policy (no inline script), clickjacking protection,
nosniffand noX-Powered-By(via helmet); HSTS is opt-in withhealthCheck.hsts - ✅ Interactive Charts - Success rates by server, duration trends, and sync timeline visualizations; Chart.js is bundled with the service, so the UI needs no internet access
- ✅ Date Format Preferences - 11 customizable date formats including 3-letter months (persisted in browser)
- ✅ Orphaned Server Cleanup - Identify and remove historical data for decommissioned budgets
- ✅ Health Check Endpoints - HTTP endpoints for monitoring (
/health,/metrics,/ready) - ✅ Prometheus Metrics - Comprehensive metrics export for Prometheus/Grafana dashboards
- ✅ Structured Logging - Pretty console + single-line JSON log files, correlation IDs, and automatic secret redaction (passwords/tokens never written to logs)
- ✅ Enhanced Logging System - Log rotation with compression, syslog support, performance tracking, per-server log levels
- ✅ Sync History Database - SQLite persistence with query interface and CLI tool (
npm run history) - ✅ Status Tracking - Real-time health status (HEALTHY/DEGRADED/UNHEALTHY/READY)
- ✅ WebSocket Streaming - Live log broadcast to connected dashboard clients with ring buffer; the stream uses the dashboard's auth and rejects foreign origins
- ✅ Interactive Telegram Bot - Real-time commands (
/status,/history,/errors,/sync) with notifications - ✅ Multi-Channel Alerts - Email (SMTP), Telegram, ntfy, and webhooks (Slack, Discord, and a generic JSON webhook for Gotify/Home Assistant/n8n/custom)
- ✅ Honest Account Reporting - Every notification shows which accounts synced, failed (with the error), or were skipped and why (closed / not bank-linked)
- ✅ Per-Channel Notification Mode -
notifyOnSuccess(always/errors_only/never) set globally, per channel, or per webhook, so an alert channel can skip routine successes - ✅ Smart Thresholds - Configurable failure detection (consecutive failures, failure rate) applied to failures
- ✅ Rate Limiting - Failure-notification spam prevention with configurable intervals
- ✅ Missing-Payment Alerts - Compares Actual's own schedules against posted transactions per server and notifies when an expected payment is late, missing, wrong-amount, or cannot be checked (stale bank connection); see Missing-Payment Alerts
- ✅ Payments Tab - Dashboard view of every watched schedule, with an on/off switch and mute-until per alert: state, expected amount, next due date, deadline, the last 6 occurrences and the last alert sent; works without a connection to Actual; see Payments tab
- ✅ Comprehensive Testing - extensive Jest suite with enforced coverage thresholds (70% lines/functions/statements, 61% branches); live counts on the badges above
- ✅ Docker Support - Production-ready containerization (Alpine-based; live image size on the badge above)
- ✅ Security Best Practices - Non-root user, credential warnings, HTTPS enforcement
- ✅ Graceful Shutdown - Proper cleanup handlers (SIGTERM/SIGINT)
- ✅ Error Recovery - Automatic retry with exponential backoff and jitter
- ✅ Zero External Dependencies - Custom structured logger, no bloated libraries
- ✅ Extensive Documentation - 19 comprehensive guides covering all aspects
- ✅ Example Configurations - Docker Compose, Kubernetes, Prometheus, alerting
- ✅ Migration Guides - Step-by-step upgrade paths from previous versions
Get up and running in 2 minutes:
# Using Docker Hub
docker run -d \
--name actual-sync \
--restart unless-stopped \
-v ./config:/app/config:ro \
-v ./data:/app/data \
-v ./logs:/app/logs \
-p 3000:3000 \
-e PUID=1001 -e PGID=1001 \
-e TZ=America/New_York \
agigante80/actual-sync:latest
# OR using GitHub Container Registry
docker run -d \
--name actual-sync \
--restart unless-stopped \
-v ./config:/app/config:ro \
-v ./data:/app/data \
-v ./logs:/app/logs \
-p 3000:3000 \
-e PUID=1001 -e PGID=1001 \
-e TZ=America/New_York \
ghcr.io/agigante80/actual-sync:latestCreate config/config.json in your mounted directory and you're ready to go!
PUID / PGID: the container starts as root only to align its user to
PUID/PGID(default1001:1001) and fix ownership of thedataandlogsvolumes, then drops to that non-root user. Set these to match the owner of your mounted directories — on Unraid usePUID=99andPGID=100(nobody:users). Without this, a container whose volumes are owned by a different UID cannot write its database or logs.
See docs/DOCKER_DEPLOYMENT.md for complete Docker setup.
For development or manual setup:
# 1. Clone and install
git clone https://github.com/agigante80/Actual-sync.git
cd Actual-sync
npm install
# 2. Create configuration
cp config/config.example.json config/config.json
# 3. Edit configuration with your Actual Budget server details
nano config/config.json
# 4. Discover available accounts
npm run list-accounts
# 5. Test manual sync
npm run sync
# 6. Start scheduled service
npm startactual-sync is published in the Unraid Community Applications store: ca.unraid.net/apps/actual-sync.
Install it from the Apps tab (Community Applications):
- Open the Apps tab and search for
actual-sync. - Click Install and set the Config path.
- Single budget (simplest): fill in the Actual server URL / password / Sync ID template variables and start — no config file needed (Sync ID: Actual Budget → the budget → Settings → Advanced → "Sync ID"). Multiple budgets: leave those blank; on first start the container drops a
config.example.jsonin the Config folder and exits — fill it in, rename it toconfig.json, and restart (see Configuration). - Open the dashboard via the container's WebUI link (port
3000).
The Unraid template lives in unraid/actual-sync.xml.
Single-server via environment variables (any Docker host, not just Unraid): set
ACTUAL_SYNC_SERVER_URL,ACTUAL_SYNC_SERVER_PASSWORD, andACTUAL_SYNC_SERVER_SYNC_IDto run one budget with noconfig.json. See Configuration.
- Node.js 22+
- Actual Budget Server - Self-hosted instance with configured bank connections
- GoCardless/Nordigen - Open banking API credentials configured in Actual Budget
- Docker (optional) - For containerized deployment
Pull from Docker Hub or GitHub Container Registry:
# Option A: Docker Hub
docker pull agigante80/actual-sync:latest
# Option B: GitHub Container Registry
docker pull ghcr.io/agigante80/actual-sync:latest
# Run container
docker run -d \
--name actual-sync \
--restart unless-stopped \
-v $(pwd)/config:/app/config:ro \
-v $(pwd)/data:/app/data \
-v $(pwd)/logs:/app/logs \
-p 3000:3000 \
-e PUID=1001 -e PGID=1001 \
-e TZ=America/New_York \
agigante80/actual-sync:latestDocker Compose:
version: '3.8'
services:
actual-sync:
image: agigante80/actual-sync:latest # or ghcr.io/agigante80/actual-sync:latest
container_name: actual-sync
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./config:/app/config:ro
- ./data:/app/data
- ./logs:/app/logs
environment:
- PUID=1001 # set to 99 on Unraid (nobody)
- PGID=1001 # set to 100 on Unraid (users)
- TZ=America/New_York
- NODE_ENV=productionSee docs/DOCKER_DEPLOYMENT.md for advanced Docker configuration.
# Clone repository
git clone https://github.com/agigante80/Actual-sync.git
cd Actual-sync
# Install dependencies
npm install
# Verify installation
npm test
npm run list-accounts --help# Install as systemd service
sudo cp actual-sync.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable actual-sync
sudo systemctl start actual-syncConfiguration is managed via config/config.json with JSON schema validation.
{
"servers": [
{
"name": "Main",
"url": "https://budget.example.com",
"password": "your_secure_password",
"syncId": "your_sync_id_from_actual",
"dataDir": "/app/data/main",
"encryptionPassword": "MyBudgetEncryptionKey"
}
],
"sync": {
"maxRetries": 5,
"baseRetryDelayMs": 3000,
"schedule": "0 2 * * *"
},
"logging": {
"level": "INFO",
"format": "pretty",
"logDir": "/app/logs"
},
"healthCheck": {
"port": 3000,
"host": "0.0.0.0"
}
}Optional top-level "timezone" (an IANA name such as "America/New_York") sets the zone cron schedules run in and times are shown in; default is the process timezone (TZ). See CONFIG.md.
Configuration Notes:
encryptionPassword(optional): Required only if your budget file has end-to-end encryption (E2EE) enabled. This is separate from the server password.logDir: Set to/app/logsfor Docker deployments, or./logsfor local development. Set tonullto disable file logging and log only to console.
### Configuration Validation
```bash
# Validate configuration file
npm run validate-config
# Or manually
node -e "require('./src/lib/configLoader'); new (require('./src/lib/configLoader'))().load()"
See docs/CONFIG.md for complete configuration reference including:
- Advanced multi-server configurations
- Per-server schedule overrides
- Notification setup (Email, Telegram, Slack, Discord, ntfy, generic webhooks)
- Retry logic customization
- Timezone configuration
- Environment variable alternatives
| Script | Command | Description |
|---|---|---|
npm start |
node index.js |
Start scheduled sync service (background) |
npm run sync |
node index.js --force-run |
Run immediate sync for all servers (skip schedule wait) |
npm run sync -- --server "ServerName" |
node index.js --force-run --server "ServerName" |
Run immediate sync for specific server only |
npm run list-accounts |
node scripts/listAccounts.js |
List all configured bank accounts |
npm run history |
node scripts/viewHistory.js |
View sync history and statistics |
npm run validate-config |
node scripts/validateConfig.js |
Validate configuration file |
npm run screenshots |
node scripts/generateDashboardScreenshots.js |
Generate dashboard screenshots from the e2e fixture harness |
npm test |
jest |
Run test suite |
npm run test:watch |
jest --watch |
Run tests in watch mode |
npm run test:coverage |
jest --coverage |
Generate coverage report |
npm run test:e2e |
node --experimental-vm-modules node_modules/.bin/jest --config jest.e2e.config.js --runInBand |
Run the browser E2E suite (see TESTING.md) |
List all accounts:
npm run list-accounts
# Output:
# Server: Main
# ✓ Connected successfully
#
# Accounts:
# 1. Chase Checking (ID: acct_123) - Last sync: 2025-12-07
# 2. Savings Account (ID: acct_456) - Last sync: 2025-12-06
# 3. Credit Card (ID: acct_789) - Last sync: 2025-12-07Run sync for all servers:
npm run syncRun sync for a specific server:
npm run sync -- --server "Main Budget"
# Example output:
# Starting sync for server: Main Budget
# ✅ Sync completed successfullyView sync history:
npm run history
# Options:
npm run history -- --server Main --days 7 --status failed
npm run history -- --stats
npm run history -- --errorsIf you've configured the Telegram bot, you can interact with the service:
/status- Show current sync status and health/history [count]- View recent sync history (default: last 5)/errors [count]- Show recent errors (default: last 5)/stats- Show sync statistics/servers- List configured servers/sync ServerName- Trigger manual sync for specific server/notify [always|errors|never]- Change notification preferences at runtime (the command'serrorsis the config'serrors_only)/help- Show all available commands/ping- Test bot connectivity/stats- Show sync statistics/help- List available commands
See docs/NOTIFICATIONS.md for Telegram bot setup.
Actual-sync is published to two registries:
- Docker Hub:
agigante80/actual-sync:latest - GitHub Container Registry:
ghcr.io/agigante80/actual-sync:latest
Both registries contain identical multi-platform images (amd64/arm64).
version: '3.8'
services:
actual-sync:
image: agigante80/actual-sync:latest # or ghcr.io/agigante80/actual-sync:latest
container_name: actual-sync
restart: unless-stopped
ports:
- "3000:3000" # Health check endpoint
volumes:
- ./config:/app/config:ro # Read-only config
- ./data:/app/data # Persistent data (SQLite)
- ./logs:/app/logs # Log files
environment:
- PUID=1001 # Run-as UID (99 on Unraid)
- PGID=1001 # Run-as GID (100 on Unraid)
- TZ=America/New_York # Timezone for schedules
- NODE_ENV=production
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- actual-network
networks:
actual-network:
external: trueDeploy:
docker-compose up -d
# View logs
docker-compose logs -f actual-sync
# Check health
docker-compose ps
curl http://localhost:3000/healthSee docs/DOCKER_DEPLOYMENT.md for Kubernetes deployment and advanced configurations.
Actual-sync provides comprehensive logging with multiple output formats and destinations.
Configure logging in config/config.json:
{
"logging": {
"level": "INFO",
"format": "json",
"logDir": "/app/logs",
"rotation": {
"enabled": true,
"maxSize": "10M",
"maxFiles": 10,
"compress": "gzip"
}
}
}ERROR: Critical failures requiring immediate attentionWARN: Warnings and retry attemptsINFO: Normal operations (recommended for production)DEBUG: Detailed tracing for troubleshooting
Pretty Format (Human-readable, for development):
2025-12-09T22:06:15.204Z [INFO] Starting sync for server: Vega II
2025-12-09T22:06:15.564Z [INFO] Connected to Actual server
2025-12-09T22:06:16.440Z [INFO] Accounts fetched successfully
JSON Format (Machine-readable, for production):
{"timestamp":"2025-12-09T22:06:15.204Z","level":"INFO","service":"actual-sync","message":"Starting sync for server: Vega II","server":"Vega II","correlationId":"9a313fa1-8901-4e3f-a167-5e3e1e8cbee3"}File Logging (Docker):
# Logs are written to /app/logs inside the container
# Mount this directory to persist logs on the host
docker run -v ./logs:/app/logs agigante80/actual-sync:latest
# View logs
tail -f ./logs/actual-sync-2025-12-09.logConsole Only (Disable file logging):
{
"logging": {
"level": "INFO",
"format": "pretty",
"logDir": null
}
}Docker Logs (Recommended for Docker deployments):
# View real-time logs
docker logs -f actual-sync
# View last 100 lines
docker logs --tail 100 actual-sync
# View logs since specific time
docker logs --since 2025-12-09T00:00:00 actual-syncWhen file logging is enabled, logs are automatically rotated to prevent disk space issues:
- Max Size: 10MB per file (configurable)
- Max Files: 10 files kept (configurable)
- Compression: Old logs compressed with gzip
- Daily Rotation: New file created each day
See docs/LOGGING.md for advanced logging configuration including syslog support and performance tracking.
Access the interactive dashboard at http://localhost:3000/dashboard:
Healthy System - Main Dashboard View
Real-time monitoring with success metrics, interactive charts, and live log streaming
Degraded System - Multiple Servers with Errors
Dashboard showing failure detection across multiple budget servers with detailed error tracking
Multi-Server Setup - 6 Budget Instances
Managing multiple budget instances with per-server status and consolidated metrics
The Payments tab lists every schedule watched by Missing-Payment Alerts, grouped per server, with a "Sync now" button per server. It reads the snapshot saved by each sync, so it works even when Actual is unreachable.
| Pill | Meaning | Needs attention |
|---|---|---|
| Upcoming (outline) | not due yet | no |
| Received (green) | paid on time, right amount | no |
| Late (amber) | paid after the deadline | no |
| Missing (red) | deadline passed, no payment | yes |
| Wrong amount (amber) | paid, but not the expected amount | yes |
| Cannot check (grey) | the bank connection is too stale to tell | yes |
| Skipped (grey, dashed) | skipped in Actual | no |
See docs/DASHBOARD.md for dates, refresh and failure messages.
The last column of the tab switches an alert off or on and mutes it until a date, without editing config.json or restarting. It needs healthCheck.dashboard.allowConfigWrites: true and dashboard auth other than none (otherwise the controls are shown disabled, with a tooltip saying why).
The pill always shows what Actual says; a separate badge beside it shows "Muted until " or "Disabled in config". A mute needs a reason. The alert keeps being evaluated while muted, and when the mute ends each destination gets one summary message if anything was held back (nothing if not). Unmuting early sends no summary.
Pause the alert while a parking spot is vacant. Click Mute... on the parking row, pick the date the spot is let again, and write "vacant until 1 March". Nothing is sent for that alert until the end of that day. If the rent was missed meanwhile, you then get one message such as:
🔔 Rent - Parking: mute ended (1 Jan 2026 to 1 Mar 2026, "vacant until 1 March"). 1 event(s) while muted: missing for 5 Feb 2026;
Stop an alert permanently. There are two ways. Put "enabled": false on the alert in config.json (see the gym alert in config/config.example.json): it stays visible in the tab with a "Disabled in config" badge, and the dashboard cannot switch it on. Or use the switch on the row: it is stored by the dashboard, can be flipped back at any time, and the config setting wins over it.
The reason is stored in the sync-history database, shown in the dashboard as text, and included in the summary message, so it goes to your notification channels. Remove {{reason}} from the muteEnded template if you do not want that. See docs/SCHEDULE_ALERTS.md for the exact rules and docs/SECURITY_AND_PRIVACY.md for how the reason is stored and cleared.
Dashboard Capabilities:
- 📈 Real-time Charts - Success rates, duration trends, sync timelines
- 🖥️ System Status - Live uptime, statistics, and server health
- 🎮 Manual Controls - Trigger syncs for all servers or individual ones
- 📡 Live Logs - WebSocket-streamed logs with color-coding
- 📊 Metrics Visualization - Interactive Chart.js graphs
- 🔒 Authentication - Optional basic auth or token-based security
Quick Access:
# Default (no auth)
open http://localhost:3000/dashboard
# With authentication configured
curl -u admin:password http://localhost:3000/dashboardRegenerating Screenshots:
When dashboard features change, regenerate screenshots:
# Start the service first
npm start
# In another terminal, generate screenshots
npm run screenshotsSee docs/DASHBOARD.md for complete dashboard documentation including authentication setup, API endpoints, and reverse proxy configuration.
The dashboard is read-only for configuration by default. State-changing requests (sync, dismiss, reset, test notification) are refused when they come from another browser origin.
"dashboard": { "enabled": true }Enable writes only with authentication:
"dashboard": {
"enabled": true,
"allowConfigWrites": true,
"auth": { "type": "basic", "username": "admin", "password": "change-me" }
}Behind a reverse proxy that serves the page under another origin, list it (this also authorizes POSTs from it):
"dashboard": { "allowedOrigins": ["https://budget.example.com"] }Every configuration change is listed, with secrets redacted, in the Settings tab:
Actual-sync exposes HTTP endpoints for monitoring:
| Endpoint | Description | Response |
|---|---|---|
GET /health |
Basic alive check | 200 OK or 503 Service Unavailable |
GET /metrics |
Detailed sync statistics | JSON with per-server status |
GET /ready |
Kubernetes readiness probe | 200 OK when service is ready |
GET /dashboard |
Web dashboard UI | HTML dashboard interface |
WS /ws/logs |
Live log stream (dashboard auth, same-origin only) | JSON log records |
Example - Health Check:
curl http://localhost:3000/health
# Response (200 OK):
{
"status": "HEALTHY",
"uptime": 86400,
"timestamp": "2025-12-07T14:32:10.123Z",
"version": "1.4.0"
}Actual-sync exports Prometheus metrics on port 3000:
curl http://localhost:3000/metrics/prometheusPrometheus Configuration:
# prometheus.yml
scrape_configs:
- job_name: 'actual-sync'
static_configs:
- targets: ['actual-sync:3000']
scrape_interval: 30s
metrics_path: /metrics/prometheusSee docs/PROMETHEUS.md and docs/HEALTH_CHECK.md for complete monitoring setup including Grafana dashboards.
Actual-sync can send notifications on sync results — success, partial, or failure — via multiple channels. Each channel decides for itself which results are worth a message, so an alert channel can stay quiet on routine successes:
- Email - SMTP (Gmail, SendGrid, custom)
- Telegram - Interactive bot with commands
- Slack - Webhook integration
- Discord - Webhook integration
- ntfy - Push notifications to an ntfy topic (priority + tags)
- Generic webhook - POSTs a documented JSON payload to any URL (works with Gotify, Home Assistant, n8n, Apprise, or custom endpoints)
notifyOnSuccess decides which sync results reach a channel. Set it globally under notifications, and override it per channel — or per individual webhook entry:
always(default) — notify on every syncerrors_only— notify on failures and partial syncs, stay silent on clean successesnever— turn the channel off entirely, failures included
"notifications": {
"notifyOnSuccess": "errors_only",
"email": { "notifyOnSuccess": "always" }
}Test notifications from the dashboard always send, so you can still verify a muted channel.
Failures only. Once a channel is set to notify on a failure, these decide whether that failure is worth alerting about — they do not apply to success or partial results:
- Consecutive Failures: Alert after N consecutive failed syncs (default: 3)
- Failure Rate: Alert when failure rate exceeds X% over Y minutes (default: 50% over 60 min)
Failures only, like thresholds. To quieten routine success notifications, use notifyOnSuccess: "errors_only" above — rate limiting will not do it:
- Minimum Interval: Don't send notifications more frequently than X minutes (default: 15)
- Maximum Per Hour: Don't send more than X notifications per hour (default: 4)
Notification wording is being made user-configurable, using Handlebars syntax: the same engine and {{ variable }} placeholders Actual Budget uses for rule action templates, so Actual users already know it. A template is validated at startup: an unknown variable, an unregistered helper, or (for Telegram) literal markup Telegram would reject all fail fast, naming the offending token and line rather than failing silently at send time.
English example:
Spanish example:
A server's scheduleAlerts.alerts[].templates (or block-level scheduleAlerts.templates) block is the first configuration surface that uses this engine - see Missing-Payment Alerts below. See docs/NOTIFICATIONS.md for the full syntax, the variable and helper whitelist, and the per-channel output rules.
See docs/NOTIFICATIONS.md for complete notification setup guide including configuration examples for all channels.
Actual-sync can watch a server's own Actual schedules and tell you when an expected recurring payment did not behave as expected - a rent that never went out, a salary that arrived for the wrong amount, a bank connection too stale to trust. It never invents its own cadence: the schedule's own recurrence, in Actual, is the single source of truth.
Enable it per server with a scheduleAlerts block:
{
"servers": [
{
"name": "Main",
"scheduleAlerts": {
"staleAfterDays": 3,
"alerts": [
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6 },
{ "id": "salary", "schedule": "Salary", "graceDays": 3, "earlyDays": 5, "amountTolerancePct": 0 }
]
}
}
]
}A server with no scheduleAlerts block never calls getSchedules() and pays no cost for this feature. The step runs once per sync, after the bank-sync loop, inside the same Actual session; any error in it is logged at WARN and never changes the sync's own recorded result.
Every scenario below (except #9, which needs a shorter cadence to demonstrate) uses the same
schedule, configured in Actual as: recurrence Monthly, on the 5th; amount -850.00 (an
expense); account Checking; payee Landlord LLC; name "Rent - Apartment". Timezone is
Europe/Madrid, dateFormat is left at its default (D MMM YYYY), and the destination channel is
a configured Slack webhook unless noted. Each message below is rendered by the real deliver()
code against that exact fixture, not paraphrased.
1. On time - a transaction for -850.00 posts to Checking on 5 Oct 2026 (the occurrence date itself), linked to the schedule in Actual.
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6 }No event, no message: an on-time payment produces nothing to report.
2. Missing - nothing has posted to Checking by the deadline (5 Oct + graceDays: 6 = 11 Oct),
and the account's last_sync is past that deadline. Sync runs on 12 Oct 2026.
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6 }
⚠️ Rent - Apartment did not go out. -850.00 to Landlord LLC from Checking was due 5 Oct 2026.
3. Resolved - the sync on 12 Oct sends the missing alert above. Three days later, on 15 Oct,
the payment posts (4 days after the 11 Oct deadline). The next sync closes it out with a resolved
alert; no manual dismissal is needed.
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6 }
⚠️ Rent - Apartment did not go out. -850.00 to Landlord LLC from Checking was due 5 Oct 2026.(3 days later, once the payment posts:)
✅ Rent - Apartment: -850.00 arrived 15 Oct 2026, 4 day(s) after the deadline. Alert closed.
4. Wrong amount - a transaction for -800.00 (not -850.00) posts to Checking, matching account
and payee within the window, on 5 Oct 2026. The rule's amount mode is exact (Actual's schedule
amount is is, not approximately), so amountTolerancePct defaults to 0.
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6 }
⚠️ Rent - Apartment: received -800.00 on 5 Oct 2026, expected -850.00 (difference 50.00).
5. Stale connection - nothing has posted, and Checking's last_sync is 8 Oct 2026 while the
sync runs on 15 Oct - 7 days old, more than staleAfterDays: 3. A cannotCheck alert (reason
stale) fires instead of a false missing.
{
"staleAfterDays": 3,
"alerts": [{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6 }]
}⏸️ Cannot check Rent - Apartment: Checking has not synced since 8 Oct 2026 (deadline 11 Oct 2026). Check the bank connection.
6. Not yet synced - the sync runs on 13 Oct 2026 (past the 11 Oct deadline), and Checking's
last_sync is 11 Oct - fresh enough not to be stale (2 days old, under staleAfterDays: 3), but
still at/before the deadline itself, so there has been no chance yet to see the payment. A
cannotCheck alert (reason not-synced) fires instead of missing.
{
"staleAfterDays": 3,
"alerts": [{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6 }]
}⏸️ Cannot check Rent - Apartment: Checking has not synced since 11 Oct 2026 (deadline 11 Oct 2026). Check the bank connection.
7. Recurring reminder - the same missing alert as scenario 2 stays unresolved. With
remindEveryDays: 3 set, it repeats every 3 calendar days (in the configured timezone) instead of
firing once and going silent: the sync on 12 Oct sends it, and the sync on 15 Oct (3 days later,
still unresolved) sends the identical message again rather than staying silent.
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6, "remindEveryDays": 3 }
⚠️ Rent - Apartment did not go out. -850.00 to Landlord LLC from Checking was due 5 Oct 2026.(repeated verbatim 3 days later, since the payment is still missing)
⚠️ Rent - Apartment did not go out. -850.00 to Landlord LLC from Checking was due 5 Oct 2026.
8. Renamed or deleted schedule - the rule watches "Rent - Apartment (old name)", but the
schedule in Actual was renamed to "Rent - Apartment" (or deleted). The rule's own schedule/
schedulePrefix matches nothing.
{ "id": "rent", "schedule": "Rent - Apartment (old name)", "graceDays": 6 }❓ Payment alert "rent" matches no schedule in Main. Check the schedule name in Actual.
This is sent once per destination while the rule stays unmatched, not on every sync. It is sent again only after the rule matches a schedule again and later stops matching.
9. Overlapping windows - a weekly schedule (7-day interval between occurrences) with
graceDays: 5, earlyDays: 3 (5 + 3 = 8, greater than or equal to the 7-day interval): a late
payment for one week's occurrence could be counted as an early payment for the next, or vice versa.
The rule is skipped for that sync rather than risk a wrong result - one cannotCheck alert (reason
interval-violation, no occurrence/deadline since none is being evaluated) plus a logged
warning naming the rule and the exact numbers.
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 5, "earlyDays": 3 }Logged warning:
Rule "rent": graceDays (5) + earlyDays (3) >= the schedule's shortest interval between occurrences (7 days); the on-time and late windows of consecutive occurrences would overlap, so this rule was skipped.
Sent alert (the built-in cannotCheck template has its own wording per reason, so this one points
at the rule, not at the bank connection):
⏸️ Cannot check Rent - Apartment: the rule's graceDays plus earlyDays reach the schedule's interval between occurrences, so its windows would overlap. Shorten them in config.json.
10. Digest - in the same sync, the Rent rule above is missing and a second rule, watching an
income schedule "Salary" (recurrence Monthly on the 5th, expected +3200.00 into Checking from
"Employer Inc"), receives +3000.00 instead - a wrongAmount. With digest: true set on the block,
both are merged into one message per channel instead of two separate messages; the ledger still
records one row per event, per destination, so de-duplication and reminders work exactly as without
digest.
{
"staleAfterDays": 3,
"digest": true,
"alerts": [
{ "id": "rent", "schedule": "Rent - Apartment", "graceDays": 6 },
{ "id": "salary", "schedule": "Salary", "graceDays": 3, "earlyDays": 5, "amountTolerancePct": 0 }
]
}Actual-sync: 2 payment alert(s) for Main
⚠️ Rent - Apartment did not go out. -850.00 to Landlord LLC from Checking was due 5 Oct 2026.⚠️ Salary: received 3000.00 on 5 Oct 2026, expected 3200.00 (difference -200.00).
Every template (built-in or an override under templates) can reference these; each has a raw (_raw) counterpart carrying the underlying value (a number or an unformatted ISO date) for a template author who wants to format it differently.
| Variable | Meaning |
|---|---|
name |
The alert's schedule name (or the rule's id, for ruleUnmatched) |
payee |
The schedule's payee name |
account |
The schedule's account name |
direction |
expense or income, derived from the expected amount's sign |
expected_amount / expected_amount_raw |
The schedule's expected amount |
expected_date / expected_date_raw |
The occurrence's calendar date |
deadline / deadline_raw |
Occurrence date + graceDays |
grace_days |
The rule's configured graceDays |
period |
The occurrence's month (or the following month, when period: "next") |
cadence |
Actual's own recurrence wording (e.g. "Every month on the 5th") |
days_overdue / days_overdue_raw |
Days past the deadline |
received_amount / received_amount_raw |
The amount actually received |
received_date / received_date_raw |
The date the matching transaction posted |
difference / difference_raw |
received_amount - expected_amount (for wrongAmount) |
last_sync / last_sync_raw |
The account's last successful sync date (for cannotCheck) |
reason |
Why a cannotCheck was raised: stale, not-synced, interval-violation or error (empty for other events) |
closed_by |
completed when a resolved closes the alert because the one-off schedule was marked completed in Actual, deleted when the schedule was removed in Actual (empty otherwise) |
budget |
The server name |
alert_id |
The rule's id |
A variable that does not apply to the current event type is always present as null, so a template can reference it without an {{#if}} guard.
Every date in this feature - an occurrence date, a deadline, "today", account staleness - is computed in one timezone: the top-level timezone config key (see CONFIG.md), falling back to the process timezone (TZ, else the system zone), and to UTC when that is blank or not a valid IANA name. All servers share it.
See docs/SCHEDULE_ALERTS.md for the full configuration reference, the algorithm's edge cases, and message-template overrides.
Actual-sync has comprehensive test coverage to ensure reliability.
# Run all tests
npm test
# Watch mode (for development)
npm run test:watch
# Generate coverage report
npm run test:coverage--------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------|---------|----------|---------|---------|-------------------
All files | ... | ... | ... | ... |
--------------------|---------|----------|---------|---------|-------------------
Test Suites: all passing
Tests: all passing # live count + coverage are on the badges at the top
Time: ~8 s
See docs/TESTING.md for complete testing guide including:
- Test structure and organization
- Writing new tests
- CI/CD integration
- Coverage thresholds
DO:
- ✅ Store credentials in
config/config.json(git-ignored) - ✅ Use strong passwords (16+ characters, mixed complexity)
- ✅ Rotate credentials periodically (quarterly recommended)
- ✅ Use HTTPS for all Actual Budget server URLs
DON'T:
- ❌ Hardcode credentials in source files
- ❌ Commit
config/config.jsonto version control - ❌ Share credentials in public channels
- ❌ Use same password across multiple servers
- Non-Root Container - Docker runs as
actualuser(UID 1001), not root - Read-Only Config - Mount config as read-only in Docker
- HTTPS Enforcement - Warnings for HTTP connections in production
- Rate Limiting - HTTP endpoints protected (60 req/min per IP), and dashboard logins lock out after 10 failures per IP in 15 minutes
- SQL Injection Protection - Parameterized queries throughout
- Input Validation - Startup business-logic validation, plus hard-fail JSON-schema validation (type/range/required/format/pattern/enum; unknown keys warn) for all config
Dependency vulnerabilities are tracked live on the repository's Security tab (Dependabot alerts), so this README carries no static security score that could go stale. The codebase's standing practices:
- ✅ No hardcoded credentials
- ✅ SQL injection protection (parameterized queries throughout)
- ✅ Container security best practices (non-root user, read-only config)
- ✅ Startup config validation and automatic secret redaction in logs
See docs/SECURITY_AND_PRIVACY.md for details.
Please report security vulnerabilities privately via GitHub's Report a vulnerability form (Security tab → Report a vulnerability). Do not open public GitHub issues for security vulnerabilities. The full policy (supported versions, what to include and what to leave out, response targets) is in SECURITY.md.
# Solution: Create from example
cp config/config.example.json config/config.json# Solution: Validate configuration
npm run validate-config# 1. Verify server URL is accessible
curl https://budget.example.com
# 2. Check credentials
npm run list-accounts
# 3. Check logs for detailed error messages
tail -f logs/actual-sync-*.logError: Rate limit exceeded (429)
Solutions:
- Increase
sync.baseRetryDelayMs(e.g., 5000) - Reduce sync frequency (e.g.,
"0 */12 * * *"for every 12 hours) - Check GoCardless/Nordigen API limits
# 1. Check logs
docker logs actual-sync
# 2. Verify volume mounts
docker inspect actual-sync | grep -A 10 Mounts
# 3. Run interactively to debug
docker run --rm -it \
-v $(pwd)/config:/app/config:ro \
actual-sync:latest \
npm run validate-configEnable detailed logging for troubleshooting:
{
"logging": {
"level": "DEBUG",
"format": "pretty"
}
}- Check Documentation - See docs/ for comprehensive guides
- View Logs - Check
logs/actual-sync-*.logfor error details - Run Diagnostics - Use
npm run list-accountsto test connectivity - GitHub Issues - Create issue with logs and configuration (redact credentials)
Comprehensive documentation is available in the docs/ directory:
- docs/README.md - Documentation index and quick links
- docs/CONFIG.md - Complete configuration reference
- docs/MIGRATION.md - Upgrade guide from previous versions
- docs/ARCHITECTURE.md - System design and components
- docs/CONFIG.md - Full configuration reference
- docs/DOCKER_DEPLOYMENT.md - Docker deployment guide
- docs/HEALTH_CHECK.md - Health check endpoints and monitoring
- docs/DASHBOARD.md - Web dashboard
- docs/PROMETHEUS.md - Prometheus metrics and Grafana dashboards
- docs/LOGGING.md - Structured logging guide
- docs/SYNC_HISTORY.md - Sync history database and CLI tool
- docs/NOTIFICATIONS.md - Notification setup (Email, Telegram, Slack, Discord, ntfy, generic webhooks)
- docs/SCHEDULE_ALERTS.md - Missing-payment alerts (schedule vs. transaction detection, config reference, templates)
- docs/MIGRATION.md - Env-var to config.json migration
- docs/TESTING.md - Testing guide and coverage
- docs/VERSIONING.md - Semantic versioning and release process
- docs/SECURITY_AND_PRIVACY.md - Security best practices
- CLAUDE.md - Developer & AI-agent guide (commands, architecture, conventions)
- docs/CI_CD.md - CI/CD pipeline and release process
- docs/UNRAID_CA_PUBLISHING.md - Publishing to Unraid Community Apps
This project automatically monitors and updates dependencies to ensure compatibility with the latest Actual Budget releases:
- Daily Checks - GitHub Actions workflow runs daily at 6:00 AM UTC
- Version Detection - Compares current
@actual-app/apiversion with npm registry - Automatic PRs - Creates pull request when new versions are available
- CI/CD Pipeline - Merging triggers automated build and release
- Docker Images - New images published to Docker Hub and GHCR
- 🔔 GitHub notifications when updates are available
- 📝 Detailed PRs with version comparison and testing checklist
- 🚀 Automatic builds after merging (multi-platform: amd64/arm64)
- 📦 Tagged releases with semantic versioning
When you see an update notification:
# Review the PR on GitHub
# After merge completes (~10 min), pull new image:
docker compose pull actual-sync
docker compose up -d actual-sync
# Verify updated version:
docker exec actual-sync-service node -e \
"console.log(require('@actual-app/api/package.json').version)"Actual Budget releases include database migrations. Running an outdated API version against a newer server causes sync failures:
Error: out-of-sync-migrations
No budget file is open
Automated updates ensure your deployment stays compatible with your Actual Budget server version.
We welcome contributions! Here's how to get started:
# 1. Fork and clone
git clone https://github.com/agigante80/Actual-sync.git
cd Actual-sync
# 2. Install dependencies
npm install
# 3. Create feature branch
git checkout -b feature/amazing-feature
# 4. Make changes and test
npm test
npm run test:coverage
# 5. Commit changes
git commit -m "feat: add amazing feature"
# 6. Push and create PR
git push origin feature/amazing-feature- Code Style - Follow existing patterns, use ESLint
- Tests Required - All new features must have tests (keep the enforced coverage thresholds: 70% statements/functions/lines, 61% branches)
- Documentation - Update relevant docs in
docs/directory - Commit Messages - Use conventional commits (feat, fix, docs, chore, etc.)
- Pull Requests - Include description, tests, and documentation updates
- GitHub Actions - Reference every action by full commit SHA with a
# vX.Y.Zcomment, never a tag or branch (CI fails otherwise; see docs/CI_CD.md)
See CLAUDE.md for developer and AI-agent guidelines (commands, architecture, conventions).
This project is licensed under the MIT License - see the LICENSE file for details.
You are free to use, modify, and distribute this software for any purpose, including commercial use, as long as you include the original copyright notice and license.
- Actual Budget - The amazing open-source budgeting tool
- @actual-app/api - Official Actual Budget API client
- GoCardless/Nordigen - Open banking API provider
- better-sqlite3 - Synchronous SQLite library
- Jest - Delightful JavaScript testing framework
- Prometheus - Monitoring and alerting toolkit
- Actual Budget Community - For building an incredible open-source budgeting ecosystem
- Node-RED - For design inspiration on service orchestration
- Home Assistant - For monitoring and automation patterns
- 📖 Documentation - docs/README.md
- 💬 GitHub Discussions - Ask questions
- 🐛 Bug Reports - Create issue
- 💡 Feature Requests - Create issue
- 🔐 Security Issues - Report a vulnerability (private)
- Actual Budget Discord - Join server
- Reddit - r/ActualBudget
Stars and the project's release/CI/license/Docker badges are in the badge row at the top of this README.
I build and maintain this in my own time. It is free, it stays free, and it gets maintained either way.
If it saved you some time and you feel like saying thanks, you can do that at github.com/sponsors/agigante80. Entirely optional, and nothing about the project changes either way.
Made with ❤️ for the Actual Budget community









