---
title: "Scripting and CI"
description: "Run the BankSync CLI non-interactively in scripts and CI: API key auth, JSON output, stable exit codes, and machine-readable errors."
section: "CLI"
canonical: "https://banksync.io/docs/cli/scripting-and-ci"
---

The CLI is built to be scriptable first. In a non-interactive context it authenticates from an environment variable, emits JSON, never prompts, and reports failure through stable exit codes and machine-readable errors, so a script or pipeline can branch on results without parsing prose.

## Authenticate without logging in

In CI you do not run `login`. Provide the key through `BANKSYNC_API_KEY` and every command uses it:

```bash
export BANKSYNC_API_KEY="bsk_your_api_key_here"
banksync banks list
```

Store the key as a secret in your CI provider, never in the repository. Use a least-privilege key: a reporting job needs only read scopes.

## Output is JSON when piped

Because the default format is JSON whenever stdout is not a terminal, scripts get structured output with no flag. You can still be explicit with `--json`:

```bash
banksync banks list | jq '.data | length'
banksync tx list --bank amex --json > txns.json
```

The top-level shape is always `{ "data": ..., "meta": ... }`, so `.data` is a reliable path. See [output formats](/docs/cli/output-formats) for the full envelope.

## The CLI never blocks in CI

When there is no terminal, the CLI behaves as if `--no-input` were set: it never prompts and never waits. A missing required argument becomes an error, not a hang. `CI=true` is treated as non-interactive too. Destructive commands refuse to run without `--yes`:

```bash
banksync feeds delete fed_123 --yes
```

Use `--quiet` to suppress spinners and notices if you want data only, though these already go to stderr and are auto-suppressed when piped.

## Exit codes

Every command exits with a stable code that classifies the outcome. Branch on these rather than parsing messages:

| Code | Meaning                                            |
| ---- | -------------------------------------------------- |
| 0    | Success                                            |
| 1    | Generic or unexpected error (including 5xx)        |
| 2    | Invalid usage or bad request (400)                 |
| 3    | Unauthenticated or forbidden (401 or 403)          |
| 4    | Resource not found (404)                           |
| 5    | Conflict, for example a sync already running (409) |
| 6    | Semantic validation failed (422)                   |
| 7    | Rate limited (429)                                 |
| 8    | Provider does not support this capability (501)    |
| 9    | Backend unavailable or consent required (503)      |

The same map is available at runtime from `banksync commands --json` under `exit_codes`, so an agent or script can read it without hard-coding.

## Machine-readable errors

In any non-table format (or when piped), an error is a single JSON object on stderr while stdout stays empty. The shape is stable:

```json
{
  "error": {
    "code": "conflict",
    "message": "A sync is already running for this feed.",
    "status": 409,
    "hint": "A conflicting operation is in progress. For syncs, pass --force.",
    "retry_after": 60
  }
}
```

`code` is a stable snake\_case identifier (never the raw API message), `status` is the HTTP status when there was one, `hint` is a short suggestion, and `retry_after` (seconds) appears on rate-limit errors. On a terminal you instead get a short human `Error` line with a dim hint.

## Retries

Idempotent GET requests retry automatically on rate limits (honoring `Retry-After`) and transient network errors, with backoff. Control it with `--max-retries <n>` or disable it with `--no-retry`. Non-GET requests are never retried.

## A bash script

This example reads data and fails cleanly on any error:

```bash
#!/usr/bin/env bash
set -euo pipefail

export BANKSYNC_API_KEY="${BANKSYNC_API_KEY:?set BANKSYNC_API_KEY}"

# Fail the script if the CLI exits nonzero; capture JSON for processing.
banks=$(banksync banks list --json)
echo "$banks" | jq -r '.data[] | "\(.id)\t\(.name)\t\(.connectionStatus.status)"'

# Branch on a specific failure class using the exit code.
if ! banksync feeds sync fed_123 --watch; then
  code=$?
  if [ "$code" -eq 5 ]; then
    echo "A sync was already running; passing --force" >&2
    banksync feeds sync fed_123 --force --watch
  else
    echo "Sync failed with exit code $code" >&2
    exit "$code"
  fi
fi
```

## GitHub Actions

Install the CLI, authenticate from a secret, and export a report artifact:

```text
name: Nightly transactions export
on:
  schedule:
    - cron: "0 6 * * *"
jobs:
  export:
    runs-on: ubuntu-latest
    steps:
      - name: Install the BankSync CLI
        run: curl -fsSL https://banksync.io/install.sh | sh
      - name: Export transactions
        env:
          BANKSYNC_API_KEY: ${{ secrets.BANKSYNC_API_KEY }}
        run: |
          banksync tx list --bank amex --all -o ndjson > transactions.ndjson
      - uses: actions/upload-artifact@v4
        with:
          name: transactions
          path: transactions.ndjson
```

Because the runner is non-interactive, the CLI emits NDJSON without a format flag; the explicit `-o ndjson` here makes the intent clear and stable.

## Next steps

- [For agents](/docs/cli/for-agents): the same guarantees, framed for AI agents.
- [Output formats](/docs/cli/output-formats): the JSON envelope and NDJSON streaming.
- [Telemetry](/docs/cli/telemetry): what is collected and how to opt out in CI.
