Canonical contract:
openapi.yamlat the repo root is the canonical machine-readable spec. This Markdown file is the prose companion and may lag the spec — when in doubt, the OpenAPI document wins. CI fails on drift between the two viaTestOpenAPIDrift(internal/api/openapi_drift_test.go).
Complete list of all REST API endpoints. All endpoints are prefixed with /api/v1/ unless noted otherwise.
All API endpoints require an API key via the X-API-Key header:
X-API-Key: bb_your_api_key_here
API keys are created from the admin dashboard under API Keys. Keys can be scoped as full_access or read_only. Write endpoints require full_access scope.
- Positive = money out (debits, purchases, fees)
- Negative = money in (credits, deposits, refunds)
- Always check
iso_currency_code-- never sum across different currencies
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /health |
No | Basic liveness check |
| GET | /health/live |
No | Basic liveness check |
| GET | /health/ready |
No | Readiness check (verifies DB + scheduler) |
| GET | /api/v1/version |
No | Current server version and update availability |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /accounts |
Read | List all accounts |
| GET | /accounts/{id} |
Read | Get a single account |
| GET | /accounts/{id}/detail |
Read | Get account detail with last 25 transactions and per-currency balances |
| PATCH | /accounts/{id} |
Write | Partially update mutable fields on a single account |
Returns the standard AccountResponse fields plus:
| Field | Type | Description |
|---|---|---|
display_name |
string | null | User-supplied display label (overrides name in admin UI) |
excluded |
bool | When true, the account is hidden from totals and skipped during sync |
provider |
string | plaid, teller, or csv |
connection_user_name |
string | Household member who owns the connection (display name) |
connection_short_id |
string | Connection short_id (also surfaced as connection_id on the base shape) |
balances |
array | One entry per currency (single-element today; future-proofs for multi-currency accounts). Each entry has iso_currency_code, balance_current, balance_available, balance_limit. |
recent_transactions |
array | Up to 25 most recent transactions for this account, sorted date-DESC. Each entry uses the standard TransactionResponse shape. |
Body: every field is optional. Omit a key to leave the corresponding column unchanged. To clear display_name and fall back to the institution-supplied name, send an explicit empty string.
| Field | Type | Description |
|---|---|---|
display_name |
string | User-supplied label. Empty string clears the override. |
is_excluded |
bool | Hide the account from totals and skip it during sync. |
is_dependent_linked |
bool | Whether the account participates in dependent-link attribution. Normally driven by account_links lifecycle; exposed here for parity with the admin UI. |
owner_user_id |
string | short_id of a household member to own this account, overriding the connection owner. Empty string clears the override so the account inherits its connection owner. The effective owner drives per-user transaction attribution. |
{
"display_name": "Joint Checking",
"is_excluded": false,
"is_dependent_linked": true,
"owner_user_id": "u8Xk2p9q"
}Response: 200 OK with the updated AccountResponse. Returns 404 NOT_FOUND when the account doesn't exist.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /transactions |
Read | Query transactions with filters and pagination |
| GET | /transactions/count |
Read | Count transactions matching filters |
| GET | /transactions/summary |
Read | Aggregated totals by category, month, week, day |
| GET | /transactions/merchants |
Read | Merchant-level stats (count, total, avg) |
| GET | /transactions/{id} |
Read | Get a single transaction |
| GET | /transactions/{id}/annotations |
Read | List the activity-timeline rows for a transaction (comments, rule applications, tag/category changes). Mirror of MCP list_annotations. |
| PATCH | /transactions/{id}/category |
Write | Set transaction category (override) |
| DELETE | /transactions/{id}/category |
Write | Reset transaction category to provider default |
| POST | /transactions/batch-categorize |
Write | Batch categorize multiple transactions (max 500) |
| POST | /transactions/bulk-recategorize |
Write | Bulk recategorize by filter (server-side UPDATE) |
| POST | /transactions/update |
Write | Atomic multi-field batch (category + tags + comment per row, max 50 ops) |
| DELETE | /transactions/{id} |
Write | Soft-delete a transaction (sets deleted_at; hidden from all reads). |
| POST | /transactions/{id}/restore |
Write | Restore a soft-deleted transaction. |
| Parameter | Type | Description |
|---|---|---|
account_id |
string | Filter by account |
user_id |
string | Filter by user (includes attributed transactions) |
category_slug |
string | Filter by category slug |
start_date |
string | Start date (YYYY-MM-DD) |
end_date |
string | End date (YYYY-MM-DD) |
min_amount |
float | Minimum amount |
max_amount |
float | Maximum amount |
search |
string | Search name/merchant (comma-separated for OR) |
exclude_search |
string | Exclude matching name/merchant (min 2 chars) |
search_mode |
string | contains (default), words, fuzzy |
pending |
bool | Filter by pending status |
tags |
string | Comma-separated slugs; result transactions must carry every slug (AND) |
any_tag |
string | Comma-separated slugs; result transactions must carry at least one slug (OR) |
sort_by |
string | date (default), amount, provider_name |
sort_order |
string | desc (default), asc |
fields |
string | Field selection. Aliases: minimal, core, category, timestamps |
cursor |
string | Pagination cursor (only with date sort) |
limit |
int | Results per page (default 100, max 500) |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /categories |
Read | List all categories (2-level hierarchy) |
| GET | /categories/export |
Read | Export categories as TSV |
| GET | /categories/{id} |
Read | Get a single category |
| POST | /categories |
Write | Create a category |
| PUT | /categories/{id} |
Write | Update a category |
| DELETE | /categories/{id} |
Write | Delete a category |
| POST | /categories/import |
Write | Import categories from TSV |
| POST | /categories/{id}/merge |
Write | Merge a category into another |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /users |
Read | List all family members |
| GET | /users/{id} |
Read | Get a single household member |
| POST | /users |
Write | Create a household member |
| PATCH | /users/{id} |
Write | Update mutable fields (name, email) |
| DELETE | /users/{id} |
Write | Delete a household member (refused while bank connections still attach) |
| POST | /users/{id}/wipe-data |
Write | Destructive — remove all bank connections, accounts, and transactions belonging to this user |
{id} accepts either the user's UUID or 8-char short_id.
Create a household member. Mirrors the admin "Add family member" form.
{
"name": "Charlie",
"email": "charlie@example.com"
}name(required) — trimmed; empty value returns400 INVALID_PARAMETER.email(optional) — must parse as RFC 5322. A duplicate email returns409 EMAIL_CONFLICT.
Responds 201 with the created UserResponse.
Partial update — every field is optional, omitted fields stay unchanged. Pass email: "" (a non-null empty string) to clear the stored email.
{ "name": "Renamed", "email": "new@example.com" }Responds 200 with the updated UserResponse. 404 NOT_FOUND when the id cannot be resolved; 409 EMAIL_CONFLICT when the new email is already taken.
Hard delete. Refuses with 409 USER_HAS_DEPENDENTS whenever the user still owns one or more bank connections — call POST /users/{id}/wipe-data first if you really mean to remove them.
Responds 204 on success, 404 NOT_FOUND when the id is unknown.
Destructive: deletes every bank_connections, accounts, and transactions row attached to this user, in a single transaction. Useful as a prerequisite to DELETE /users/{id} and as a "reset" hook for development.
{ "status": "ok", "deleted_transactions": 3 }Responds 200 with the count of removed transactions, 404 NOT_FOUND when the user does not exist.
A "login account" is the auth identity that can sign in to the admin UI; each one is linked to exactly one household member (user). Headless deployments manage these via the endpoints below to provision admin access without an interactive setup session.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /users/{user_id}/login |
Write | List the login account(s) for a user |
| POST | /users/{user_id}/login |
Write | Create a login account for a user; returns the one-time setup_token |
| PATCH | /users/{user_id}/login/{login_id} |
Write | Update the role (admin, editor, or viewer) |
| DELETE | /users/{user_id}/login/{login_id} |
Write | Delete the login account (does not delete the user) |
| POST | /users/{user_id}/login/{login_id}/regenerate-token |
Write | Regenerate the setup token; returns the new plaintext token |
All login-account endpoints require write scope even for reads — the list of login identities is sensitive (it lets a leaked key enumerate who can sign in).
{user_id} accepts either the user's UUID or 8-char short_id. {login_id}
is the auth-account UUID (returned as id on every response).
Request:
{
"username": "alice@example.com",
"role": "admin"
}username is treated as an email address (validated) and must be unique
across the entire deployment. role is one of admin, editor, or
viewer.
Success response (201 Created):
{
"id": "0d6a8e02-...",
"user_id": "8c3f...",
"user_name": "Alice",
"user_email": null,
"username": "alice@example.com",
"role": "admin",
"has_password": false,
"setup_token": "f3a1c4...",
"setup_token_expires_at": "2026-05-16T12:34:56Z",
"created_at": "2026-05-09T12:34:56Z",
"updated_at": "2026-05-09T12:34:56Z"
}The setup_token is the one-time secret the user redeems to set their
initial password. It is exposed only on this POST response and on
POST .../regenerate-token. Subsequent GET and PATCH responses never
include it; if it's lost, regenerate a fresh one.
Error codes:
400 INVALID_PARAMETER— missing/invalidusername(must be an email), missingrole, or unknownrole.404 NOT_FOUND—user_iddoes not exist.409 USERNAME_TAKEN— another login already uses that username.409 LOGIN_EXISTS— this user already has a login account.
Request:
{ "role": "viewer" }Returns 200 OK with the updated login account (no setup_token).
Returns 204 No Content. The linked household member is not deleted; only
the auth identity is removed.
Returns 200 OK with a fresh plaintext token:
{ "setup_token": "9b2d4f..." }Fails with 409 PASSWORD_ALREADY_SET if the account already redeemed a token
and chose a password — there's nothing to regenerate at that point.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /providers |
Read | List configured providers with capabilities + credential schema |
| GET | /providers/{name} |
Read | Single provider entry (same shape as one entry in the list) |
| POST | /providers/{name}/link-session |
Write | Start the hosted-UI session for name. Returns {link_token, expiration} for providers that need one; 204 No Content for Teller/CSV. |
| POST | /connections |
Write | Create a connection. Body has a provider discriminator + credentials blob whose shape depends on provider. Also accepts multipart/form-data when provider=csv. |
POST /connections body:
{
"provider": "plaid",
"user_id": "<uuid-or-short-id>",
"credentials": { "public_token": "...", "institution_id": "...", "institution_name": "..." }
}GET /providers returns a bare JSON array with name, configured, needs_link_session, capabilities, and credentials_schema. Unconfigured providers still appear with configured: false so clients can render a "set me up" CTA without guessing. Discover supported providers and credential shapes programmatically rather than hardcoding the list.
Errors on POST /connections:
| Status | Code | When |
|---|---|---|
| 400 | INVALID_PARAMETER |
missing required credential per the provider schema |
| 404 | NOT_FOUND |
unknown provider name, unknown user |
| 502 | PROVIDER_ERROR |
exchange failed at the upstream provider |
| 500 | INTERNAL_ERROR |
unexpected server failure |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /connections |
Read | List all bank connections |
| GET | /connections/{id} |
Read | Get full connection detail (status, paused, sync interval, account count) |
| GET | /connections/{id}/status |
Read | Get connection status and last sync info |
| POST | /connections/{id}/sync |
Write | Trigger a sync for this single connection |
| POST | /connections/{id}/paused |
Write | Pause or resume scheduled syncs for a connection |
| DELETE | /connections/{id} |
Write | Soft-disconnect a connection (clears tokens, hides from list) |
| POST | /connections/{id}/reauth |
Write | Start the provider re-auth flow — returns a fresh link token |
| POST | /connections/{id}/reauth-complete |
Write | Mark connection active again after the user finishes the re-auth flow |
| POST | /connections/plaid/link-token |
Write | Deprecated — use POST /providers/plaid/link-session. |
| POST | /connections/plaid/exchange |
Write | Deprecated — use POST /connections with provider:"plaid". |
| POST | /connections/teller |
Write | Deprecated — use POST /connections with provider:"teller". |
| POST | /connections/csv/preview |
Write | Parse a CSV upload and return inferred columns + the first N rows. No persistence. |
| POST | /connections/csv/import |
Write | Deprecated — use POST /connections with provider:"csv". |
{id} accepts either the connection's UUID or 8-char short_id.
The CSV endpoints are the headless analog of the admin "Import CSV" wizard. They cover deployments without Plaid or Teller.
Both endpoints accept two transports with identical configuration fields. Pick whichever your client makes easier:
| Transport | How the file arrives | How config arrives |
|---|---|---|
multipart/form-data |
file form-file field |
other fields as form values; column_mapping as a JSON string |
application/json |
csv_base64 (or csv_data) string with the file base64-encoded |
sibling JSON fields |
Configuration fields
| Field | Type | Required for | Notes |
|---|---|---|---|
column_mapping |
object {name: index} |
import | maps the canonical fields (date, amount, description, optional category, merchant_name, debit, credit) to 0-indexed CSV columns |
date_format |
string | recommended | Go time-layout (e.g. "2006-01-02"); auto-detected when omitted |
positive_is_debit |
bool | optional | bank convention defaults to false (positive = credit); set true when the source CSV already follows the Breadbox convention (positive = money out) |
has_debit_credit |
bool | optional | set true for Capital-One-style two-column amount CSVs; pair with debit and credit keys in column_mapping |
user_id |
string | new connection | UUID or short_id of the household member; falls back to the only user in single-user households |
account_name |
string | new connection | display name; defaults to "CSV Import" |
connection_id |
string | append-only | existing CSV connection's UUID or short_id; the new rows attach to its first account |
limit |
int | preview only | max preview rows, default 10, capped at 100 |
POST /connections/csv/preview returns 200 OK with parsed headers, the first N rows, and inferred column mapping; nothing is persisted. template_name, positive_is_debit, date_format, and has_debit_credit are emitted only when a known bank template is auto-detected.
POST /connections/csv/import returns 201 Created:
{
"connection_id": "ab12cd34",
"account_id": "ef56gh78",
"imported_transactions": 125,
"updated_transactions": 0,
"skipped_duplicates": 3,
"total_rows": 128
}Re-importing the same CSV is a safe no-op — every row hashes to the same provider_transaction_id and the upsert detects the dedup. A transaction's category is preserved across re-imports — raw re-sync never touches category_id (it is enrichment owned by rules/agents/users).
Errors:
| Status | Code | When |
|---|---|---|
| 400 | INVALID_PARAMETER |
missing/bad fields, malformed CSV, missing column_mapping |
| 404 | NOT_FOUND |
connection_id provided but doesn't exist (or isn't a CSV connection) |
| 413 | PAYLOAD_TOO_LARGE |
upload exceeds 50 MB |
| 415 | UNSUPPORTED_MEDIA_TYPE |
Content-Type is neither multipart nor JSON |
| 500 | INTERNAL_ERROR |
unexpected server failure |
See CSV import on docs.breadbox.sh for the full CSV format reference (supported delimiters, BOM handling, dedup hash, bank template list).
Full per-connection detail. Returns 200 with the connection record:
{
"id": "01J...",
"short_id": "abc12345",
"user_id": "u1abc234",
"user_name": "Alice",
"provider": "plaid",
"institution_id": "ins_3",
"institution_name": "Chase",
"status": "active",
"error_code": null,
"error_message": null,
"last_synced_at": "2026-05-09T15:00:00Z",
"created_at": "2026-04-01T12:00:00Z",
"updated_at": "2026-05-09T15:00:00Z",
"paused": false,
"sync_interval_override_minutes": null,
"consecutive_failures": 0,
"account_count": 3
}A non-existent id returns 404 NOT_FOUND. Disconnected connections are still returned (with status: "disconnected") — only the /connections list filters them out.
Per-connection variant of POST /sync (body-less). The handler returns 202 Accepted and runs the sync in the background.
{ "status": "sync_triggered" }A non-existent or disconnected connection returns 404 NOT_FOUND (the sync resolver hides disconnected rows).
Toggle the paused flag — paused connections are skipped by the cron scheduler but can still be synced manually via POST /connections/{id}/sync.
Body
| Field | Type | Description |
|---|---|---|
paused |
bool | Required. true to pause, false to resume. |
Returns 200 with the full connection detail (same shape as GET /connections/{id}). 404 NOT_FOUND if the connection is missing.
Soft-disconnect: flips status to disconnected, wipes the encrypted access token, and soft-deletes related transactions in a single DB transaction. The row is preserved (FK policy is SET NULL for accounts and transactions, CASCADE for sync_logs) so historical data stays linked.
Returns 204 No Content. Calling on a missing or already-disconnected connection returns 404 NOT_FOUND (idempotent at the API surface).
Provider-side credential revocation (e.g. Plaid's /item/remove) is not performed by the REST endpoint — it is admin-handler-only because the public service layer doesn't carry the provider registry. The connection is unusable from Breadbox's side regardless.
Starts a provider re-auth flow for a connection in pending_reauth (or any
broken) state. Calls the provider for a short-lived link token; the client
hands the token to the provider's UI (Plaid Link, Teller Connect, etc.) and
calls /reauth-complete once the user finishes.
Body: none.
Returns 200:
{
"link_token": "link-sandbox-...",
"expiration": "2026-05-09T16:30:00Z"
}Errors:
404 NOT_FOUND— connection doesn't exist.400 INVALID_PARAMETER— connection's provider isn't configured on this server.502 PROVIDER_ERROR— upstream provider call failed.
Marks a previously broken connection active again and clears error_code /
error_message. Call this after the user has completed the provider re-auth
UI started by /reauth. Body is ignored — Plaid's OAuth redirect path
exchanges the public token out-of-band, so no payload is required today.
Returns 200:
{ "status": "active" }Errors:
404 NOT_FOUND— connection doesn't exist.
Connecting a new Plaid bank account is a three-step dance:
- Server:
POST /connections/plaid/link-token→ returns alink_token. - Client / host: hand
link_tokento Plaid Link. The user authenticates with their bank inside Plaid's UI; on success Plaid returns apublic_tokenplus institution and account metadata to the host'sonSuccesscallback. - Server:
POST /connections/plaid/exchangewith thepublic_token→ Breadbox exchanges it for a long-lived access token, encrypts it, creates theBankConnectionrow, and upserts the accounts Plaid reports. Subsequent syncs run automatically on the global cron.
These endpoints mirror the admin POST /admin/api/link-token and
POST /admin/api/exchange-token handlers and persist data through the same
service-layer write path.
Starts a new Plaid Link session for a household member.
Body
| Field | Type | Description |
|---|---|---|
user_id |
string | Required. UUID or 8-char short_id of the user the new connection will be attached to. |
Returns 200:
{
"link_token": "link-sandbox-abc123",
"expiration": "2026-05-09T16:30:00Z"
}Errors:
400 INVALID_PARAMETER—user_idmissing or malformed; Plaid provider not configured on this server.404 NOT_FOUND—user_iddoesn't match any household member.502 PROVIDER_ERROR— upstream Plaid call failed.
Exchanges the public_token Plaid returned in Link's onSuccess for a
stored BankConnection and the accounts Plaid authoritatively reports.
Body
| Field | Type | Description |
|---|---|---|
public_token |
string | Required. The token Plaid handed back in onSuccess. |
user_id |
string | Required. UUID or 8-char short_id of the owning user. |
institution_id |
string | Required. Plaid institution identifier (e.g. ins_109511). |
institution_name |
string | Required. Display name (e.g. Chase). |
accounts |
array | Optional. The account metadata array Plaid Link's onSuccess returns (id / name / type / subtype / mask). Informational only — the rows persisted come from the provider's exchange response, not from this field. |
Returns 201:
{
"connection_id": "abc12345",
"institution_name": "Chase",
"status": "active"
}connection_id is the new connection's 8-char short_id (use it in any
downstream /connections/{id}/... call).
Errors:
400 INVALID_PARAMETER— required field missing; Plaid provider not configured on this server.404 NOT_FOUND—user_iddoesn't match any household member.502 PROVIDER_ERROR— upstream Plaid token exchange failed.500 INTERNAL_ERROR— DB write failed (the provider call already succeeded; safe to retry).
Teller's enrollment flow is materially different from Plaid's: Teller Connect runs entirely client-side without a server-issued init token, so there is no link-token step. Just one POST to register the connection:
- Client / host: load Teller Connect with the application's
TELLER_APP_ID(returned byGET /settings/providersso the host doesn't have to hard-code it). The user authenticates with their bank inside Teller's UI; on success Teller firesonSuccesswith anenrollmentpayload containingaccess_token,enrollment.id, the matched institution, and the accounts it discovered. - Server:
POST /connections/tellerwith the enrollment payload → Breadbox calls Teller'sGET /accountswith the access token to confirm the discovered accounts, encrypts the access token, creates theBankConnectionrow, and upserts the accounts Teller authoritatively reports. Subsequent syncs run automatically on the global cron.
This endpoint mirrors the admin POST /admin/api/exchange-token handler with
provider: "teller" and persists data through the same service-layer write
path as POST /connections/plaid/exchange.
Registers a new Teller connection.
Body
| Field | Type | Description |
|---|---|---|
user_id |
string | Required. UUID or 8-char short_id of the owning user. |
institution_id |
string | Optional. Teller institution identifier (e.g. chase). |
institution_name |
string | Required. Display name (e.g. Chase). Forwarded to the Teller exchange so the persisted connection carries the right label. |
access_token |
string | Required. The access_token Teller handed back in onSuccess. |
enrollment_id |
string | Required. The enrollment.id Teller handed back in onSuccess. Becomes the connection's external_id. |
accounts |
array | Optional. The account metadata array Teller's onSuccess returns (id / name / type / subtype / last_four). Informational only — the rows persisted come from Teller's GET /accounts response, not from this field. |
Returns 201:
{
"connection_id": "abc12345",
"institution_name": "Chase",
"status": "active"
}connection_id is the new connection's 8-char short_id.
Errors:
400 INVALID_PARAMETER— required field missing; Teller provider not configured on this server.404 NOT_FOUND—user_iddoesn't match any household member.502 PROVIDER_ERROR— upstream Teller call failed.500 INTERNAL_ERROR— DB write failed (the provider call already succeeded; safe to retry).
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /sync |
Write | Trigger manual sync — all active connections, or one connection if connection_id is given |
The body is optional. Omit it (or send an empty body) to enqueue every active connection for sync. Pass connection_id to scope the sync to a single connection.
| Field | Type | Description |
|---|---|---|
connection_id |
string | Optional. Connection short_id or UUID. When provided, only that connection is synced. |
{ "connection_id": "abc12345" }The handler returns 202 Accepted immediately and runs the sync asynchronously; observe progress through /sync/logs (when shipped) or by polling /connections/{id}/status. A non-existent connection_id returns 404 NOT_FOUND. A connection whose status is disconnected is treated as not-found by the sync resolver and likewise returns 404 NOT_FOUND.
{ "status": "sync_triggered" }POST /sync is fire-and-forget — these GET endpoints close the loop. They wrap the same data the admin dashboard uses, so REST clients can poll progress, audit prior runs, and read provider health without scraping HTML. All read-scope. Pair them with the connection-management endpoints under /connections/{id} for per-connection drilldowns.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /sync/logs |
Read | Paginated history with filters |
| GET | /sync/logs/{id} |
Read | Single log + per-account rows |
| GET | /sync/health |
Read | Aggregate sync health (last 24h) |
| GET | /sync/health/providers |
Read | Per-provider health summary |
| GET | /sync/stats |
Read | Aggregate stats matching the same filter set as /sync/logs |
| Query param | Type | Description |
|---|---|---|
connection_id |
string | UUID or short_id. Restrict to one connection. |
status |
string | One of in_progress, success, error. |
trigger |
string | One of cron, webhook, manual, initial. |
from |
string (RFC3339) | Lower bound on started_at (inclusive). |
to |
string (RFC3339) | Upper bound on started_at (exclusive). Must be after from. |
limit |
int | Default 50, max 200. |
cursor |
string | Opaque cursor returned by a prior page. Treat as a black box. |
Response (200 OK):
{
"sync_logs": [
{
"id": "01J...",
"connection_id": "01J...",
"institution_name": "Chase",
"trigger": "manual",
"status": "success",
"added_count": 12,
"modified_count": 1,
"removed_count": 0,
"unchanged_count": 47,
"started_at": "2026-04-26T12:00:00Z",
"completed_at": "2026-04-26T12:00:02Z",
"duration": "2.041s",
"duration_ms": 2041,
"accounts_affected": 3
}
],
"next_cursor": "eyJwIjoyfQ",
"has_more": true,
"limit": 50,
"total": 173
}error_message, friendly_error_message, and warning_message are present only when populated. The list omits the per-rule breakdown — fetch a single log to see rule_hits. 400 INVALID_PARAMETER is returned for malformed filter values; 400 INVALID_CURSOR for a cursor that fails to decode.
Path id must be the sync log UUID (sync logs do not expose a short_id alias on the REST surface). Response embeds the per-account breakdown plus the per-rule hit counts:
{
"id": "01J...",
"connection_id": "01J...",
"institution_name": "Chase",
"provider": "plaid",
"trigger": "manual",
"status": "success",
"added_count": 12,
"modified_count": 1,
"removed_count": 0,
"unchanged_count": 47,
"started_at": "2026-04-26T12:00:00Z",
"completed_at": "2026-04-26T12:00:02Z",
"duration": "2.041s",
"accounts_affected": 3,
"rule_hits": [
{ "rule_id": "01J...", "rule_name": "Coffee → Food & Drink", "count": 4 }
],
"total_rule_hits": 4,
"accounts": [
{
"id": "01J...",
"sync_log_id": "01J...",
"account_id": "01J...",
"account_name": "Checking",
"added_count": 8,
"modified_count": 1,
"removed_count": 0,
"unchanged_count": 30
}
]
}404 NOT_FOUND when the id doesn't resolve.
Aggregate over the last 24h, plus the most recent sync's status and the overall verdict (healthy, degraded, unhealthy). Useful as a single-shot dashboard probe.
{
"overall_health": "healthy",
"last_sync_time": "5 minutes ago",
"last_sync_status": "success",
"recent_sync_count": 12,
"recent_success_rate": 100.0,
"recent_error_count": 0,
"connection_errors": 0,
"next_sync_time": ""
}last_sync_time is a human-readable relative timestamp ("5 minutes ago"), not RFC3339 — it mirrors what the admin dashboard renders. Pair with /sync/logs?limit=1 if you need an absolute timestamp.
Per-provider snapshot. Keyed by provider type (plaid, teller, csv):
{
"providers": {
"plaid": {
"provider": "plaid",
"connection_count": 3,
"account_count": 7,
"last_sync_status": "success",
"last_sync_time": "5 minutes ago"
},
"teller": {
"provider": "teller",
"connection_count": 1,
"account_count": 2,
"last_sync_status": "error",
"last_sync_time": "1 hour ago",
"last_sync_error": "401 unauthorized"
}
}
}Disconnected connections are excluded from the connection / account counts (they don't sync).
Same filter set as /sync/logs (connection_id, status, trigger, from, to). Returns aggregate counters for the matching slice — useful for "out of N runs matching this filter, X succeeded" UIs without paginating the full list.
{
"total_syncs": 173,
"success_count": 168,
"error_count": 5,
"warning_count": 2,
"success_rate": 97.11,
"avg_duration_ms": 1842.5,
"total_added": 4203,
"total_modified": 87,
"total_removed": 12,
"total_unchanged": 18230
}Atomic multi-field batch. Each operation can set a category (or clear an override), add/remove tags, and attach a comment — all atomic per transaction. REST sibling of the MCP update_transactions tool.
Body
| Field | Type | Description |
|---|---|---|
operations |
array | Required. Up to 50 ops. |
on_error |
string | "continue" (default — each op runs in its own DB tx, partial failures don't undo successful items) or "abort" (whole batch is one DB tx, rolls back on first error). |
Each operation:
| Field | Type | Description |
|---|---|---|
transaction_id |
string | Required. UUID or short_id. |
category_slug |
string | Optional. Sets category_id (last-writer-wins; no provenance). Mutually exclusive with reset_category. |
reset_category |
bool | Optional. Drops the transaction back to uncategorized so rules can re-categorize. |
tags_to_add |
array | [{"slug":"..."}]. Auto-creates the tag if the slug is not yet registered. |
tags_to_remove |
array | [{"slug":"..."}]. Unknown slugs are a no-op. |
comment |
string | Optional annotation, attributed to your API key. Max 10000 chars. |
Response 200 OK
{
"results": [
{"transaction_id": "k7Xm9pQ2", "status": "ok"},
{"transaction_id": "x4Lz1mNa", "status": "error", "error": {"code": "NOT_FOUND", "message": "..."}}
],
"succeeded": 1,
"failed": 1
}Per-op errors are reported inside results[]; the top-level response is still 200. The whole call returns 400 INVALID_PARAMETER only on malformed input (empty operations, more than 50, bad on_error). In abort mode a partial-batch failure rolls back the DB transaction and the response includes aborted: true plus the partial per-op outcomes.
Soft-delete and undo. DELETE /transactions/{id} sets the row's deleted_at timestamp; every read endpoint (list, get, summary, merchants, count, …) filters on deleted_at IS NULL, so a deleted transaction immediately disappears from all responses. The DB row is preserved so POST /transactions/{id}/restore can clear deleted_at and bring it back.
Both endpoints return 204 No Content on success and write a transaction_deleted / transaction_restored annotation on the activity timeline attributed to the calling API key.
Both are idempotent at the API surface — a no-op returns 404 NOT_FOUND:
DELETEon a transaction that doesn't exist or is already soft-deleted →404.POST /restoreon a transaction that doesn't exist or isn't currently soft-deleted →404.
Path id accepts either a UUID or short_id for live (non-deleted) rows. Restore on a soft-deleted row must use the UUID — the short_id resolver itself filters on deleted_at IS NULL and won't find a deleted row by its short id.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /transactions/{id}/comments |
Read | List comments on a transaction |
| POST | /transactions/{id}/comments |
Write | Add a comment to a transaction |
| PUT | /transactions/{id}/comments/{comment_id} |
Write | Update a comment |
| DELETE | /transactions/{id}/comments/{comment_id} |
Write | Delete a comment |
GET /transactions/{id}/annotations returns the activity-timeline rows for a single transaction — comments, rule applications, tag adds/removes, and category sets. Same payload as the MCP list_annotations tool, wrapped in a { "annotations": [...] } envelope. The rendering contract (dedup, soft-delete tombstones, system-event kinds) is documented in docs/activity-timeline.md.
Path id accepts either a UUID or short_id (the same resolver as the rest of the transaction endpoints).
| Query param | Type | Description |
|---|---|---|
kind |
string (repeatable, comma-separated) | Filter by raw DB kind: comment, rule_applied, tag_added, tag_removed, category_set, sync_started, sync_updated. Both ?kind=comment&kind=rule_applied and ?kind=comment,rule_applied are accepted. |
actor_type |
string (repeatable, comma-separated) | Filter by actor type: user, agent, system. Same repeatable / comma-separated handling as kind. |
since |
string (RFC3339) | Return only rows created strictly after this timestamp. Pair with limit to bound a delta read. |
limit |
int | Cap the returned rows to the most recent N (timeline tail), still ordered ASC. 0 (default) returns the full timeline; the server caps at 200. Negative values are rejected. |
raw |
bool | When true, bypass enrichment and dedup. Returns the unmodified DB view — rule-source duplicates and same-actor adjacent comment-vs-tag-note pairs survive, and the derived summary / action / subject fields are empty. |
Example:
curl -H "X-API-Key: $BB_API_KEY" \
"https://breadbox.example.com/api/v1/transactions/abc12345/annotations?kind=comment&limit=5"{
"annotations": [
{
"id": "01J...",
"short_id": "ann7zk2x",
"transaction_id": "01J...",
"kind": "comment",
"actor_type": "user",
"actor_name": "Alice",
"content": "needs receipt",
"created_at": "2026-04-26T12:00:00Z"
}
]
}A 400 INVALID_PARAMETER is returned for malformed since timestamps or non-numeric / negative limit values; 404 NOT_FOUND is returned when the path id can't be resolved (the same MCP-shared limitation applies — a syntactically valid but unknown UUID returns an empty list rather than 404).
The review queue is a tag. Transactions carrying the seeded needs-review tag (or any operator-defined trigger tag) are the backlog. A seeded on_create system rule auto-attaches needs-review to every newly-synced transaction; disable that rule to opt out. When removing a tag, passing a rationale note is optional — if provided, it's recorded on the tag_removed annotation.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /tags |
Read | List all registered tags |
| POST | /tags |
Write | Create a tag. Body: {"slug":"...","display_name":"...","description":"...","color":"#abc123","icon":"tag"}. Slug must match ^[a-z0-9][a-z0-9\-:]*[a-z0-9]$. Returns 409 SLUG_CONFLICT if the slug is already registered. |
| GET | /tags/{slug} |
Read | Get a single tag by UUID, short_id, or slug |
| PATCH | /tags/{slug} |
Write | Partial update — every field optional: {"display_name":"...","description":"...","color":"...","icon":"...","lifecycle":"persistent|ephemeral"}. Slug is immutable. |
| DELETE | /tags/{slug} |
Write | Delete a tag. Cascades to transaction_tags; annotations referencing the tag retain tag_id=NULL. Returns 204 No Content. |
| POST | /transactions/{id}/tags |
Write | Attach a tag to a transaction (body: {"slug":"...","note":"..."}). Auto-creates the tag if the slug is not yet registered. Idempotent — returns already_present: true on repeat calls. |
| DELETE | /transactions/{id}/tags/{slug} |
Write | Detach a tag from a transaction. Optional ?note=... or JSON body {"note":"..."} recorded on the tag_removed annotation. Idempotent — returns already_absent: true when the tag isn't attached. |
For filtering, pass tags=slug1,slug2 (AND) or any_tag=slug1,slug2 (OR) to /transactions and /transactions/count.
Additional tag-touching operations exposed via MCP: list_tags, add_transaction_tag, remove_transaction_tag, create_tag, update_tag, delete_tag, update_transactions, list_annotations. The admin dashboard covers the same ground at /tags, /transactions/:id/edit, and bulk actions on /transactions.
Rules auto-categorize transactions during sync by matching conditions on transaction fields.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /rules |
Read | List all rules with filters |
| GET | /rules/{id} |
Read | Get a single rule |
| GET | /rules/{id}/sync-history |
Read | Last N sync runs that triggered this rule (default 10, max 100) |
| POST | /rules |
Write | Create a rule |
| POST | /rules/batch |
Write | Bulk-create rules (mirrors the MCP create_transaction_rule rules array, max 50 per call) |
| PUT | /rules/{id} |
Write | Update a rule |
| DELETE | /rules/{id} |
Write | Delete a rule |
| POST | /rules/{id}/apply |
Write | Apply a single rule retroactively (set_category last-writer-wins) |
| POST | /rules/apply-all |
Write | Apply all active rules retroactively (pipeline-stage order) |
| POST | /rules/preview |
Write | Dry-run a condition against existing transactions |
POST /rules/batch and the apply endpoints both have integration coverage in internal/api/rules_batch_integration_test.go and internal/api/rules_apply_integration_test.go. Provenance/precedence was removed in P3 of the rules-substrate sprint — retroactive apply writes category_id directly (last-writer-wins); rules only re-run on new or changed transactions, which is what keeps a manual edit from being continuously re-clobbered.
POST /rules/batch request body mirrors the MCP create_transaction_rule rules-array shape:
{
"rules": [
{ "name": "...", "category_slug": "...", "conditions": { ... } },
{ "name": "...", "actions": [ ... ], "trigger": "always" }
],
"on_error": "continue"
}on_error defaults to continue (per-op failures are isolated). With on_error=abort, the first failure rolls back any rules created earlier in the batch and returns a per-op results[] envelope with aborted: true. Top-level 400 INVALID_PARAMETER is reserved for malformed input (empty rules array, > 50 entries, unknown on_error).
Rules use a recursive JSON condition tree supporting AND/OR/NOT logic:
{
"type": "and",
"conditions": [
{ "field": "provider_name", "operator": "contains", "value": "AMAZON" },
{ "field": "amount", "operator": "gt", "value": 50 }
]
}Available fields: provider_name, provider_merchant_name, amount, provider_category_primary, provider_category_detailed, pending, provider, account_id, user_id, user_name
String operators: eq, neq, contains, not_contains, matches (regex), in
Numeric operators: eq, neq, gt, gte, lt, lte
Boolean operators: eq, neq
POST /rules and PUT /rules/{id} accept either stage (semantic) or priority (raw integer) on the request body:
| Field | Type | Description |
|---|---|---|
stage |
string | baseline / standard / refinement / override. Resolves to priority 0 / 10 / 50 / 100. |
priority |
int | Raw pipeline-stage integer, 0..1000. Lower runs first. |
If both are supplied, priority wins. If neither is supplied on create, the rule defaults to standard (priority 10). Stage names are case-insensitive; unknown stage strings return 400 VALIDATION_ERROR. See docs/rule-dsl.md for the full priority-as-pipeline-stage model.
Account links connect dependent (authorized user) accounts to primary (cardholder) accounts for cross-connection transaction deduplication.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /account-links |
Read | List all account links |
| GET | /account-links/{id} |
Read | Get a single link with match stats |
| GET | /account-links/{id}/matches |
Read | List matched transaction pairs (cursor-paginated; see below) |
| POST | /account-links |
Write | Create a link (auto-runs initial reconciliation) |
| PUT | /account-links/{id} |
Write | Update a link |
| DELETE | /account-links/{id} |
Write | Delete a link |
| POST | /account-links/{id}/reconcile |
Write | Re-run matching for a link |
| POST | /transaction-matches/{id}/confirm |
Write | Confirm a matched pair |
| POST | /transaction-matches/{id}/reject |
Write | Reject a matched pair |
| POST | /transaction-matches/manual |
Write | Manually match two transactions |
Cursor-paginated. Query parameters:
| Param | Type | Default | Description |
|---|---|---|---|
limit |
int | 50 |
Page size. Max 200. |
cursor |
string | — | Opaque cursor from a previous next_cursor. |
Response envelope mirrors the transactions list:
{
"matches": [{ "id": "...", "short_id": "...", "...": "..." }],
"next_cursor": "opaque-string-or-empty",
"has_more": false,
"limit": 50
}AI agents can submit summaries and flag transactions for human review.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /reports |
Read | List all reports (bare array, bounded) |
| GET | /reports/unread-count |
Read | Count of unread reports |
| GET | /reports/{id} |
Read | Get a single report |
| POST | /reports |
Write | Submit a report |
| PATCH | /reports/{id}/read |
Write | Mark a report as read |
| PATCH | /reports/{id}/unread |
Write | Mark a report as unread (returns it to the unread queue) |
| POST | /reports/read-all |
Write | Mark every unread report as read |
| DELETE | /reports/{id} |
Write | Hard-delete a report |
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | Yes | Short headline shown in the reports list. |
body |
string | Yes | Markdown body. May include transaction references, tables, etc. |
priority |
string | No | low, normal (default), high. Surfaced in the admin list view. |
tags |
string[] | No | Free-form labels for filtering (e.g., ["monthly-summary", "groceries"]). |
author |
string | No | Override display name for the report author. Defaults to the authenticated actor (API key name). |
{
"rules_applied": { "<rule_id>": 17, "<rule_id>": 4 },
"total_affected": 21
}rules_applied is an object keyed by rule ID, mapping to the number of transactions updated by that rule in this retroactive pass. Order is not guaranteed. total_affected is the sum across all rules.
Manage the API keys that authenticate X-API-Key requests against this server. Useful for headless deployments that need to rotate credentials without a browser session.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api-keys |
Write | List all keys (hashed prefixes only — plaintext is never returned) |
| POST | /api-keys |
Write | Create a new key. Response includes plaintext_key once — store it. |
| DELETE | /api-keys/{id} |
Write | Soft-revoke (sets revoked_at); subsequent requests using the key get 401 REVOKED_API_KEY |
All three endpoints are gated by Write scope. Listing keys (even with hashes) reveals every credential's name, prefix, and last-used timestamp — enumeration that should not be available to read-only callers. The same scope applies to POST and DELETE for symmetry.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Human label shown in the admin Access page. |
scope |
string | No | full_access (default) or read_only. |
{
"id": "...",
"name": "deploy-bot",
"key_prefix": "bb_aBcD1234",
"scope": "full_access",
"last_used_at": null,
"revoked_at": null,
"created_at": "2026-05-09T18:30:00Z",
"plaintext_key": "bb_aBcD1234...long..."
}The plaintext_key field is the only time the full key is returned. List and delete responses never include it; the server stores only the SHA-256 hash.
Read and write the configuration for the bank-data providers (Plaid, Teller). These endpoints mirror the admin form handlers under /settings/providers so a headless install can bootstrap providers without an admin UI session.
Persistence and hot-reload semantics match the admin flow:
- Values are stored in the
app_configDB table. - Sensitive fields (Plaid secret, Teller cert/key PEM) are AES-256-GCM encrypted at rest using
ENCRYPTION_KEY— the server fails fast at startup if the key is missing when any provider is configured. - Sensitive fields are redacted on
GET— only boolean*_setflags are returned. Raw secret/cert bodies are never exposed via REST. - Empty / omitted sensitive fields on
PUTpreserve the existing stored value — so a caller updatingwebhook_urldoesn't need to retype the secret. - After a successful
PUT, the live provider is re-initialized in-process so subsequent syncs use the new credentials immediately.
If a provider is configured via environment variables (PLAID_CLIENT_ID / TELLER_APP_ID), PUT returns 409 PROVIDER_FROM_ENV — env-driven config is the source of truth and cannot be overridden via the API.
Returns the current (redacted) view of both providers.
Scope: read.
Response 200:
{
"plaid": {
"configured": true,
"from_env": false,
"client_id": "abc-123",
"environment": "sandbox",
"webhook_url": "https://example.com/wh",
"secret_set": true
},
"teller": {
"configured": false,
"from_env": false,
"environment": "sandbox",
"certificate_set": false,
"webhook_secret_set": false
}
}configured is true when all required fields for that provider are present (Plaid: client_id + secret; Teller: application_id + cert/key pair).
Set or update Plaid credentials.
Scope: write.
Request body:
{
"client_id": "abc-123",
"secret": "your-plaid-secret",
"environment": "sandbox",
"webhook_url": "https://example.com/wh"
}| Field | Required | Notes |
|---|---|---|
client_id |
yes | |
secret |
conditionally | Required on first save. Omit (or send empty string) on subsequent saves to preserve the existing stored secret. |
environment |
no | One of sandbox, development, production. Defaults to sandbox. |
webhook_url |
no | Must use https:// if provided. |
Response 200: Same redacted shape as GET /settings/providers.
Errors:
400 INVALID_PARAMETER— missingclient_id, invalidenvironment, non-HTTPSwebhook_url, or no secret available (no existing secret stored and none provided).409 PROVIDER_FROM_ENV—PLAID_CLIENT_IDis set in the environment.500 PROVIDER_REINIT_FAILED— config saved but the live provider failed to reinitialize.500 INTERNAL_ERROR— DB write failure.
Set or update Teller credentials. The PEM cert and private key are accepted as JSON strings (suitable for cat client.pem | jq -Rs .).
Scope: write.
Request body:
{
"application_id": "app_xxxxxxxx",
"environment": "sandbox",
"certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n",
"private_key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n",
"webhook_secret": "optional-webhook-signing-secret"
}| Field | Required | Notes |
|---|---|---|
application_id |
yes | |
environment |
no | One of sandbox, development, production. Defaults to sandbox. |
certificate |
conditionally | PEM body. If both certificate and private_key are omitted (or empty), the existing encrypted cert is preserved. Must be supplied alongside private_key. |
private_key |
conditionally | PEM body for the matching private key. Must be supplied alongside certificate. |
webhook_secret |
no | Send an empty string or omit to leave the existing webhook secret unchanged. |
The certificate + private_key pair is validated server-side via crypto/tls.X509KeyPair before being encrypted and stored.
Response 200: Same redacted shape as GET /settings/providers.
Errors:
400 INVALID_PARAMETER— missingapplication_id, invalidenvironment, only one ofcertificate/private_keysupplied, or the cert/key pair fails X.509 validation.409 PROVIDER_FROM_ENV—TELLER_APP_IDis set in the environment.500 ENCRYPTION_KEY_MISSING—ENCRYPTION_KEYis not set on the server; cannot encrypt the cert.500 PROVIDER_REINIT_FAILED— config saved but the live provider failed to reinitialize.500 INTERNAL_ERROR— DB write failure or encryption error.
OAuth 2.1 endpoints for MCP client authentication:
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /.well-known/oauth-authorization-server |
No | OAuth server metadata |
| GET | /.well-known/oauth-protected-resource |
No | Protected resource metadata |
| POST | /oauth/token |
No | Token exchange |
| POST | /oauth/register |
No | Dynamic client registration |
Two shapes, picked per-resource:
-
Bounded resources (small by design: accounts, users, connections, categories, reports) return a bare JSON array:
[ { "id": "...", ... }, { "id": "...", ... } ] -
Paginated resources (transactions, rules) return a resource-keyed envelope with cursor pagination:
{ "transactions": [...], "next_cursor": "eyJ...", "has_more": true, "limit": 100 }{ "rules": [...], "next_cursor": "eyJ...", "has_more": true, "total": 248 }
The list key matches the resource name. Pass the next_cursor value as the cursor query parameter to fetch the next page. Cursor pagination only works with the default date sort on transactions.
All errors return a JSON envelope:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Human-readable description"
}
}Error codes use UPPER_SNAKE_CASE. Common codes: VALIDATION_ERROR, NOT_FOUND, UNAUTHORIZED, FORBIDDEN, RATE_LIMITED, INTERNAL_ERROR.
All /api/v1/* endpoints are rate-limited per API key using a token bucket. /health/* and /api/v1/version are exempt (used by load balancers and monitoring).
Defaults: 120 requests/minute, burst 60. Override with the API_RATE_LIMIT_RPM and API_RATE_LIMIT_BURST environment variables at server startup. Unauthenticated requests (no valid X-API-Key or Authorization: Bearer) fall back to bucketing by client IP.
Every response includes:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Bucket capacity (burst). |
X-RateLimit-Remaining |
Tokens left after this request. |
X-RateLimit-Reset |
Epoch seconds when the bucket fully refills. |
Over-limit requests return 429 Too Many Requests with code: "RATE_LIMITED" and a Retry-After header (seconds to wait before retrying):
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded; retry after 1s"
}
}