Reconcile every transaction, automatically.
Cascade ingests transaction feeds from banks, processors, and your own ledger, matches them against configurable rules, and surfaces the ones that don't line up — so your team investigates exceptions instead of spreadsheets.
Bank feed
Ledger
Cascade is API-first. Everything the console does is available over the REST API and CLI, so reconciliation can live inside your close process, your data warehouse, or an operations dashboard your team already uses.
Pick a path
Run the hosted quickstart, or self-host with Docker in about ten minutes.
ConfigureAuthor a matching rule →Define how feeds match your ledger, backtest it, and publish safely.
BuildAPI reference →Authenticate, list breaks, and subscribe to reconciliation events.
ResolveWhy didn't these match? →A diagnostic path for the question every recon team asks first.
How it fits together
Three concepts carry through every page of these docs:
- Sources are the feeds you connect — a bank statement, a processor settlement file, an internal ledger export.
- Matching rules decide which records from two sources represent the same real-world transaction, using match keys and tolerances you control.
- Breaks are what's left over: records that should have matched but didn't, or matched outside tolerance. Resolving breaks is the daily work Cascade is built to shrink.
Installation & deployment
Two supported paths: managed SaaS for teams who want to start immediately, and self-hosted for teams with data-residency or network requirements. Both reach a working reconciliation in roughly ten minutes.
app.cascade.dev and a role of admin or integrator to create API keys. Self-hosting additionally needs Docker 24+ and 4 GB of free memory.Option A — Managed SaaS
Nothing to install. Create a secret key, point the CLI at your organization, and confirm connectivity.
Create a secret key
In the console, open Settings → API keys → Create key. Copy the key that starts with sk_live_ — it's shown once. Store it in a secret manager, never in source control.
Install the CLI
npm install -g @cascade/cli
cascade auth login --key sk_live_••••••••
Verify connectivity
A healthy response returns your organization and the API version.
cascade status # ✓ authenticated as acme-payments (org_9F2k) # ✓ api v1 · region us-east · latency 41ms
Option B — Self-hosted (Docker)
Cascade self-hosts as four services: the API, the matching worker, PostgreSQL, and Redis. The compose file below runs all four on one host for evaluation. For production, see sizing below.
services: api: image: cascade/api:1.8 ports: ["8080:8080"] environment: CASCADE_DB_URL: postgres://cascade:secret@db:5432/cascade CASCADE_REDIS_URL: redis://cache:6379 CASCADE_SECRET_KEY: ${CASCADE_SECRET_KEY} depends_on: [db, cache] worker: image: cascade/worker:1.8 environment: CASCADE_DB_URL: postgres://cascade:secret@db:5432/cascade CASCADE_REDIS_URL: redis://cache:6379 depends_on: [db, cache] db: image: postgres:16 environment: POSTGRES_USER: cascade POSTGRES_PASSWORD: secret cache: image: redis:7
Set the signing secret and start
export CASCADE_SECRET_KEY=$(openssl rand -hex 32) docker compose up -d
Run database migrations
The API image ships the migration runner. This is safe to run repeatedly — it applies only what's pending.
docker compose exec api cascade-migrate up
Confirm the health check
The endpoint reports each dependency independently, so a failure tells you whether the database, cache, or worker is the problem.
curl -s localhost:8080/healthz { "status": "ok", "checks": { "db": "ok", "redis": "ok", "worker": "ok" } }
Production sizing
| Daily volume | API | Workers | PostgreSQL |
|---|---|---|---|
| Up to 100k records | 2 × 1 vCPU | 1 × 2 vCPU | 2 vCPU · 8 GB |
| 100k – 1M records | 3 × 2 vCPU | 3 × 2 vCPU | 4 vCPU · 16 GB |
| 1M+ records | Autoscale ≥ 4 | Autoscale ≥ 6 | 8 vCPU · 32 GB + replica |
cascade-migrate up after pulling a new image and before routing traffic. Migrations are backward-compatible within a minor version, so you can roll the API fleet without downtime.Next
With Cascade running, connect your first two sources and then author a matching rule →
Author a matching rule
A matching rule tells Cascade which records from two sources represent the same transaction. This guide builds a rule that matches a bank feed to your ledger on reference and amount, allows a small fee tolerance, and backtests it before it touches live data.
integrator role. This guide uses the console; every step has a CLI and API equivalent linked inline.Choose the two sources
Open Rules → New rule and select a left and right source — here, bank_ach on the left and ledger on the right. Direction only affects how breaks are labeled; matching itself is symmetric.
Define the match key
The match key is the set of fields that must agree for two records to be considered the same. Add fields in priority order. Cascade normalizes each field before comparing — trimming whitespace, upper-casing, and stripping punctuation from reference strings.
| Left field | Right field | Comparison |
|---|---|---|
reference | invoice_number | Normalized exact |
amount | total | Numeric, tolerance below |
value_date | posted_at | Within 2 days |
Set a tolerance
Real feeds carry rounding and fees, so an exact amount match creates noise. Set an absolute or percentage tolerance; the smaller of the two applies. Here we allow processor fees up to $10 or 0.5%.
"tolerance": { "field": "amount", "absolute": 10.00, "percent": 0.5, "apply": "min" }
A match inside tolerance is still a match, but Cascade records the residual so you can report on fee leakage later.
Handle many-to-one
Deposits often batch several invoices into one bank credit. Turn on Group matching so Cascade can sum a set of ledger records to match a single bank record. Cap the group size to keep combinatorial search bounded.
Backtest before publishing
Run the rule against the last 30 days in dry-run mode. Nothing is written; you get a preview of match rate and the breaks the rule would have produced.
cascade rules backtest rule_draft_8830 --window 30d # matched 18,204 (96.1%) # breaks 742 (3.9%) # amount out of tolerance 611 # no candidate 131
Read the breakdown before trusting the headline. A 96% match rate driven by loose tolerance can hide real exceptions — tighten until the breaks that remain are ones you'd genuinely want a human to see.
Publish and monitor
Publish sets the rule live for new data; it never rewrites history. Watch the match rate for the first few closes and adjust tolerance or date windows as you learn the feed's real behavior.
cascade rules publish rule_draft_8830
# ✓ published as rule_ach_ledger_v1 · effective now
Next: when breaks appear, work them from the Breaks API → or diagnose a stubborn one with Why didn't these match? →
Breaks & authentication
The Cascade REST API is organized around resources, uses predictable HTTP verbs, and returns JSON with conventional status codes. This page covers authentication, request conventions, and the Breaks resource in full.
Authentication
Authenticate every request with a secret key in the Authorization header. Keys are environment-scoped: sk_live_ for production data and sk_test_ for the sandbox. Requests over plain HTTP are rejected.
curl https://api.cascade.dev/v1/breaks \
-H "Authorization: Bearer sk_live_••••••••"
Request conventions
| Topic | Behavior |
|---|---|
| Base URL | https://api.cascade.dev/v1 |
| Versioning | Pinned in the path. Breaking changes ship under a new version; additive changes do not. |
| Pagination | Cursor-based via limit and starting_after. Responses include has_more. |
| Idempotency | Send Idempotency-Key on writes to safely retry without creating duplicates. |
| Rate limits | 100 requests/second per key. Throttled requests return 429 with a Retry-After header. |
List breaks
Returns a paginated list of breaks, most recent first. Filter by status, source, or the reconciliation run that produced them.
Query parameters
| Parameter | Type | Description |
|---|---|---|
status | string | One of open, investigating, resolved. Omit to return all. |
source | string | Return breaks involving this source id, e.g. bank_ach. |
reason | string | Filter by break reason, e.g. amount_out_of_tolerance. |
limit | integer | 1–100. Defaults to 20. |
starting_after | string | A break id. Returns the page after this object. |
Request
curl "https://api.cascade.dev/v1/breaks?status=open&limit=2" \ -H "Authorization: Bearer sk_live_••••••••"
Response
{ "object": "list", "has_more": true, "data": [ { "id": "brk_3aQ7x2Lp", "object": "break", "status": "open", "reason": "amount_out_of_tolerance", "amount": 3297.00, "currency": "usd", "residual": 7.00, "left": { "source": "bank_ach", "ref": "ACH-4472" }, "right": { "source": "ledger", "ref": "INV-2233" }, "run_id": "run_88b1", "created": 1751760000 } ] }
Resolve a break
Marks a break resolved with a reason code and optional note. Send an Idempotency-Key so a retried request doesn't double-post an audit entry.
curl https://api.cascade.dev/v1/breaks/brk_3aQ7x2Lp/resolve \ -H "Authorization: Bearer sk_live_••••••••" \ -H "Idempotency-Key: 7c1e-resolve-2233" \ -d resolution=fee_adjustment \ -d note="Processor fee, within policy"
Webhook events
Subscribe to events instead of polling. Cascade signs each delivery with an HMAC in the Cascade-Signature header; verify it before trusting the payload.
| Event | Fires when |
|---|---|
break.created | A reconciliation run produces a new break. |
break.resolved | A break is resolved, by a person or the API. |
run.completed | A reconciliation run finishes; payload includes match-rate summary. |
Errors
Cascade uses conventional HTTP status codes and returns a machine-readable error object. type is stable and safe to branch on; message is for humans and may change.
| Status | type | Meaning |
|---|---|---|
400 | invalid_request | A parameter is missing or malformed. The param field names it. |
401 | authentication_error | Missing, malformed, or revoked key. |
404 | not_found | No object with that id in this environment. |
409 | conflict | The break is already resolved. Safe to treat as success. |
429 | rate_limited | Slow down and retry after Retry-After seconds. |
Why didn't these transactions match?
This is the question every reconciliation team asks first. Work top to bottom — the branches are ordered by how often each cause is the real one. Each ends with the fix and where to make it.
reason field. It usually points straight at the branch below. If reason is no_candidate, the records never met the match key at all — start with the first two branches.The amounts differ by a small, consistent value amount_out_of_tolerance
The records matched on reference but the amounts sat outside tolerance. A small, repeating gap is almost always a processor fee, FX rounding, or a partial capture — not a genuine discrepancy.
Check the break's residual. If it's stable across many breaks (say, always 0.2%), it's structural.
Fix → Widen the amount tolerance on the rule, or add a fee field to the match. See Author a matching rule → step 3.
The reference exists on both sides but is formatted differently no_candidate
Cascade normalizes references before comparing, but it can't guess structural differences — INV2233 versus INV-2233-A, or a bank truncating a reference to 16 characters.
Open both records side by side and compare the raw reference values, not the display values.
Fix → Add a transform to the match key to strip suffixes or pad identifiers, or match on a secondary field such as a shared batch id.
The records are more than a few days apart no_candidate
Settlement lags posting. If the bank credit lands three days after the ledger entry and your date window is two, they'll never pair even when everything else agrees.
Fix → Widen the date window on the rule to cover your feed's real settlement lag. Prefer widening dates over loosening amounts — a wrong-amount match is worse than a slightly late one.
One bank deposit covers several ledger items no_candidate
A single $12,400 deposit that represents four invoices will never match any one of them individually. This shows up as one unmatched bank record and several unmatched ledger records with the same total.
Fix → Enable group matching so Cascade can sum ledger records to a single bank record. See Author a matching rule → step 4, and mind the group-size cap.
The record isn't in Cascade at all source lag
Before assuming a matching problem, confirm both records were actually ingested. A delayed or partial file upload looks identical to a match failure from the breaks view.
Check the source's last successful sync and record count for the day against what you expect.
Fix → Re-run the source sync, then re-run reconciliation for that window. If counts are still short, the gap is upstream — in the feed, not the rule.
Everything looks right and it still won't match rule scope
Confirm the two records are actually in scope for the same rule. A record only matches under a rule whose left/right sources include it; if it arrived through a third source, no rule covers it.
Fix → Check which rule owns each source in Rules, and confirm the rule was published and effective before the run. A rule never matches data that predates its effective time.
Still stuck?
Pull the run's decision log — it records, per record, which rule evaluated it and why the best candidate was rejected. That log is the ground truth when the console view isn't enough.
cascade runs explain run_88b1 --record ACH-4472 # candidate INV-2233 rejected: amount 3297.00 vs 3290.00 # residual 7.00 exceeds tolerance (min of $10 / 0.5% = $6.45)
This example break failed by 55 cents of tolerance headroom — a textbook case for branch one above.
How this documentation was made
This sample was produced with an AI-augmented, docs-as-code workflow. AI accelerated drafting and quality control; a human owned information architecture, technical correctness, and every contract-level detail. This page documents that division of labor honestly, because being able to defend it is the point.
The pipeline
Architecture — human
I chose the product, defined its surface area to force realistic doc types, and structured the set on the Diátaxis framework (tutorial, how-to, reference, explanation) so task and reference content stay separated. AI does not decide information architecture here; that judgment is the writer's.
Drafting — AI, on a leash
First drafts of prose were generated from a fixed style guide and terminology list, then rewritten for voice and cut for length. AI is strongest on how-to and reference scaffolding and weakest on concept material, so the conceptual glue was written by hand.
Quality control — AI in CI
In a real repository this stage runs on every pull request: style and terminology linting, reading-level checks, broken-link detection, and doc–spec drift detection that flags when documented endpoints diverge from the OpenAPI source. These gates catch the mechanical 80% so human review can spend its attention on correctness.
Correctness — human, non-negotiable
Every field name, status code, header, port, and command was verified for internal consistency across pages by hand. AI never gets final say on anything that behaves like a contract, because a hallucinated field name or a wrong port is exactly the failure that destroys a reader's trust.
Where the line sits
| AI accelerates | The human owns |
|---|---|
| First-draft prose from structured inputs | Information architecture and page scope |
| Example and boilerplate generation | Technical accuracy of every contract detail |
| Style, terminology, and link QA | Voice, judgment, and what to leave out |
| Drift detection against the spec | The decision to publish |
The short version: AI made the writing faster; it did not make the writer optional.
Portfolio notice
This is a writing sample, not real product documentation.
Cascade is a fictional product created solely to demonstrate technical writing across a realistic documentation surface: an overview, installation and deployment, a task-based user guide, an API reference, and a troubleshooting diagnostic. Any resemblance to a real company or product is coincidental.
All endpoints, keys, hostnames, and identifiers shown here are invented and non-functional. api.cascade.dev and app.cascade.dev do not exist; the code samples illustrate documentation conventions and will not run.
The sample was produced with the AI-augmented workflow described under How this documentation was made. It is offered as evidence of documentation craft — information architecture, task analysis, API reference discipline, and diagnostic design — and of a defensible, transparent way of working with AI in the authoring loop.
Prepared as a portfolio piece. Available to walk through the reasoning behind any page on request.