Skip to content

Troubleshooting

Common issues and how to resolve them.

Container fails to launch (browser sandbox)

Symptom: logs show Failed to launch browser or sandbox creation failed.

Fix: ensure --cap-add SYS_ADMIN is set. Camoufox/Firefox needs it for namespace sandboxing.

docker run --cap-add SYS_ADMIN ...

In docker-compose.yml:

cap_add:
  - SYS_ADMIN

"out-of-sync-migrations" from Actual Budget

Symptom: the container log shows Database is out of sync with migrations or out-of-sync-migrations, and the import fails with No budget file is open. Starting the importer and downloading the budget report no error first.

Cause: the copy of the budget in the data directory was written by a newer Actual Budget version than the image is running. Rolling the importer back causes this, for example below 1.43.2.

Fix: upgrade the importer to the latest release. If the error started after a rollback and you must stay on the older release:

  1. Stop the importer.
  2. In the data directory (actual.init.dataDir, /app/data in Docker), find the budget folder whose metadata.json has a groupId equal to your syncId.
  3. Delete that one folder.
  4. Start the importer. It downloads the budget from your Actual server again.

If the budget on your server was uploaded by a newer Actual Budget client, deleting the folder does not help.

Never empty the whole data directory. It also holds the bank tokens (bank-tokens.json; each lost token costs an SMS), paired phones, app sign-ins and the import history.

2FA / OTP requested every run

Symptom: every import asks for an OTP, even for banks that issue a long-term token (OneZero and PayBox) or keep an enrolled device (Pepper).

Cause: the importer saves the long-term token after each SMS login, in bank-tokens.json on the data volume, and sends it on the next run. An SMS every run means the token is not saved or not sent. The log lines in Long-term token say which:

  • Could not store the long-term token: the warning names the cause, most often a data volume that is not writable (EROFS, EACCES). Mount /app/data read/write, or point BANK_TOKENS_PATH at a writable file.
  • belongs to another login: the entry's email or phone number changed. That costs one SMS, then the new token is used.
  • was not accepted: the token was sent and refused, most often because a newer SMS login for the same account replaced it. The run logs in with one SMS and saves the new token. With twoFactorAuth: false it cannot, so the run fails and the warning says to turn it on.
  • The token file is damaged: the importer sets the file aside when it saves the next token, so this costs at most one SMS per account. If the old file is kept when the config password is turned on, off or changed, it also appears until the importer saves the first new token; each account then needs at most one SMS.
  • Could not read the long-term token: the warning names the cause, such as a permission error. Fix it so the importer can read and write the file.
  • Two banks entries that log in to the same account: each SMS login makes the bank refuse the other entry's token. Keep one entry per login.
  • Pepper logs Pepper device state lines instead, listed in Pepper. Could not store there fails the run, so fix the data volume as above; Removed the Pepper device state means Pepper refused it, and the next run enrolls with one SMS.

Fix: keep twoFactorAuth: true so a refused token costs one SMS, not a failed run. The logs never show the token.

Better still: auto-forward OTP codes from your phone so no manual input is needed.

PayBox asks for the OTP twice in a row

Symptom: a single PayBox login shows two back-to-back OTP prompts, and the second prompt arrives before any new SMS does.

Fix: upgrade. Since scraper 8.6.5 the scraper keeps the code for the whole login, and the importer also remembers the code you supplied for that attempt, so one login prompts once. See PayBox. If the bank rejects the code, this run asks for no new code, so a wrong code is never re-sent. See the next section.

"OTP rejected … this run asks for no new code"

Symptom: after you enter a code for OneZero, Pepper or PayBox, the log shows OTP rejected for <bank> — this run asks for no new code, and a notification says the code was rejected.

Cause: the bank refused the code, for example because of a typo or because it expired. For these banks the importer asks for at most one code per run, so it does not ask again. Other banks still ask for a fresh code in the same run.

Fix: run the import again and enter the new code.

"The scraper hid the real reason behind its one-SMS-login limit"

Symptom: a OneZero or PayBox import fails with already spent its one cold SMS login, often with no SMS code asked for. The log and Telegram add The login failed, and the scraper hid the real reason behind its one-SMS-login limit.

Cause: the run sent a long-term token, and the login failed. The bank library then tries an SMS login of its own, but it allows one per run, so it refuses and reports that refusal instead of the first failure. Sending the same token again would most likely fail the same way.

What the importer does: it retires the token. It records the token as not accepted, removes it from bank-tokens.json when it was the saved one, and warns The stored long-term token for <key> was not accepted; it is retired, so the next run logs in without it (configured for an otpLongTermToken). The next run skips that token. Unless otpLongTermToken holds a different usable token, it sends none, so it asks for one SMS code and saves the new token, or fails with the bank's real error. The importer cannot see the first failure, so it may retire a token that was still valid; that may cost one SMS login. No other banks entry sends a retired token either, while the importer keeps its record (the last 8 per entry). Deleting bank-tokens.refused.json lets a retired token be sent again; a copy that cannot be read in full lets only a token saved for the entry's login be sent again.

Fix: keep twoFactorAuth: true and run the import again. With it off, a next run that has no other usable token cannot ask for a code and fails with TWO_FACTOR_RETRIEVER_MISSING.

  • was not accepted and could not be retired: the warning before it names the cause, most often a data volume that is not writable. Fix that, and remove otpLongTermToken for the bank, or the next run may fail the same way.
  • The configured long-term token for <key> was not accepted before, so it is not sent: remove or replace otpLongTermToken. The importer saves the token from the next SMS login and sends that one instead.
  • Pepper sends no long-term token, so there is nothing to retire: run the import again.

The long-term token log lines say what each run did with the token.

One bank failed but the notification says the import failed

Symptom: four of five banks imported fine, yet Telegram reported a failure.

Fix: upgrade — failure notifications are now built from the per-bank results of the run and read ⚠️ Partial import (38s) — 4/5 banks OK, 1 failed: with the failed bank named. Use /retry to re-run only the failed banks.

The error notification says all-banks-failed

Symptom: Telegram shows 🚨 Import Failed followed by the bare token all-banks-failed, even though only one bank was actually involved.

Fix: upgrade — every bank runs in its own child process, so a lone failure used to satisfy the internal "all banks failed" condition and send that token verbatim. The message now names the bank and the reason, for example Import failed for visacal: Hard-model scrape resolved zero accounts. Runs covering several banks read All 3 banks failed — … with each bank listed.

The importer or portal stops at startup on a config read error

Symptom: the container exits at startup with one of these, naming config.json or credentials.json:

  • <path> is not a regular file: a directory, FIFO or device sits at the path. Docker creates a directory there when a single-file bind mount starts before the file exists on the host.
  • <path> is a symlink to a missing file: the link's target is gone, for example on a volume that was not mounted.
  • <path> is larger than 8 MiB
  • <path> is not valid UTF-8: the file was saved in another encoding.
  • Could not read <path>: <code>, for example EACCES when the container user cannot read it.

Fix: put a regular UTF-8 JSON file at the path, readable by the container user. A symlink to such a file is fine; a symlink to a missing file is not.

Only a config.json with nothing at its path counts as "no config": the importer logs config.json not found, using environment variables and runs from them. A broken mount that leaves one of the errors above stops the run. A mount that leaves nothing at the path, such as a missing volume entry or a volume without the file, reads as "no config" instead. If that log line appears when you expected a file, check the mount.

"config.json and credentials.json come from different saves"

Symptom: the importer and the portal refuse to start after the portal was stopped or crashed during a save.

Fix: see If a save fails or is interrupted, which shows how to go back to the previous save or keep the new one.

Windows volume mounts don't work

Symptom: bind: invalid mount config or container can't read config.json.

Fix: use absolute Windows paths and quote them:

docker run --rm --cap-add SYS_ADMIN `
  -v "C:\path\to\config.json:/app/config.json:ro" `
  -v "C:\path\to\data:/app/data" `
  sergienko4/israeli-bank-importer

"Too many transactions" or duplicate imports

Symptom: transactions appear duplicated in Actual Budget.

Fix: the importer is idempotent by transaction ID. Duplicates usually mean:

  • targets[].accounts was changed mid-import.
  • The Actual Budget account was reset without clearing ./data.

Run with DRY_RUN=true first to preview what would be imported.

Schedule doesn't fire

Symptom: importer starts, then exits — no scheduled runs.

Fix:

  1. Check SCHEDULE is set (no SCHEDULE = run once and exit).
  2. Verify the cron expression with crontab.guru.
  3. Set restart: unless-stopped so the container survives reboots.
  4. Tail docker compose logs -f — the scheduler logs the next-run time on startup.

Camoufox download fails (CI / pre-commit)

Symptom: first-time build downloads stall or 403, or the install stops with digest mismatch.

Fix: every install site runs scripts/camoufox-fetch.mjs, which downloads the build pinned in config/camoufox-pin.json straight from its release asset URL (no GitHub API call, so no API rate limit) and retries a failed download. In CI the docker/camoufox-cache composite action caches the result per pin, installer version and runner architecture. Locally, run npm run camoufox:install once, and again whenever config/camoufox-pin.json changes; it skips the download when the pinned build is already installed. A digest mismatch means the downloaded archive is not the one the pin names — retry, and if it persists, check the pin's SHA-256 against the release asset (see Bumping the Camoufox browser).

Scraper times out on Oracle Cloud / slow VMs

Symptom: banks fail with Navigation timeout of 30000ms exceeded.

Fix: set per-bank tuning:

"amex": {
  "timeout": 60000,
  "...": "..."
}

See Oracle Cloud deployment guide for details.

Container is OOM-killed or the host becomes unresponsive

Symptom: the importer disappears mid-run, docker inspect israeli-bank-importer --format '{{.State.OOMKilled}}' reports true, or — with no memory limit set — the whole host stops responding while still accepting TCP connections.

Cause: a bank scrape that exceeds its deadline used to leave its browser process running. Each retry launched another, so a single bank could hold several browsers at once and RSS climbed without bound. Fixed in v1.42.2: every attempt now closes the browsers it launched, whether it succeeded, failed, or timed out. You will see this line in the logs when a browser is reclaimed:

🧹 Reclaimed 1 abandoned browser(s) after Scraping discount

Fix:

  1. Upgrade to v1.42.2 or later.
  2. Always run with a memory limit — this is the guard that keeps a runaway container from taking the host with it:
docker run --memory 2g --memory-swap 2g ...        # docker run
mem_limit: 2g                                       # docker compose
memswap_limit: 2g

Kubernetes users set resources.limits.memory: 2Gi.

NODE_OPTIONS=--max-old-space-size does not solve this: it caps only the JS heap, and most of the importer's memory is native browser allocation outside the heap. See Docker Compose → Memory limits.

Proxy is set but ignored

Symptom: PROXY_SERVER or proxy.server has no effect.

Fix: proxy support is not yet wired to Camoufox (v7.9.0+). The config is preserved for future use. See Proxy docs.

Portal rate limit locks everyone out

Symptom: the config portal's login limiter blocks people who have barely used it, and the boot log contains ⚠️ PORTAL_TRUST_PROXY is set to a proxy hop count.

Fix: PORTAL_TRUST_PROXY stopped accepting a hop count (1, 2) in 1.42.12, because Fastify 5.12.1 removed that form. Until it names the address the proxy connects from — loopback behind tailscale serve, or an IP/CIDR — the portal trusts no forwarded header and credits every request to the proxy, so all callers share one bucket. See Upgrading.

Telegram bot stops responding

Symptom: /scan and /status no longer get replies.

Fix:

  1. Confirm listenForCommands: true is set.
  2. Check docker compose logs -f for polling error.
  3. Re-generate the bot token via @BotFather (/token).
  4. Rate limits — Telegram throttles bots that exceed 30 messages/sec. The importer batches notifications by default; only /logs N with a very large N will hit this.

Receipt OCR returns wrong amount

Symptom: /import_receipt extracts the wrong total.

Fix: OCR priority is לתשלום > סה"כ > ₪ prefix > largest formatted number. If your receipt uses non-standard layout, reply to the extraction message with the corrected amount — the bot updates the inline preview.

Still stuck?