← Back to portfolio
Cascade Docs
◆ Payment reconciliation platform

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.

Live reconciliationmatching…
Bank feed
ACH · 4471$12,400.00
WIRE · 8820$8,015.50
ACH · 4472$3,290.00
Ledger
INV-2231$12,400.00
INV-2232$8,015.50
INV-2233$3,297.00
Matched Break — amount mismatch Pending

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

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.
Start here / Installation

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.

Before you startYou'll need an organization on 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.

1

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.

2

Install the CLI

terminal
npm install -g @cascade/cli
cascade auth login --key sk_live_••••••••
3

Verify connectivity

A healthy response returns your organization and the API version.

terminal
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.

docker-compose.yml
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
1

Set the signing secret and start

terminal
export CASCADE_SECRET_KEY=$(openssl rand -hex 32)
docker compose up -d
2

Run database migrations

The API image ships the migration runner. This is safe to run repeatedly — it applies only what's pending.

terminal
docker compose exec api cascade-migrate up
3

Confirm the health check

The endpoint reports each dependency independently, so a failure tells you whether the database, cache, or worker is the problem.

terminal · GET /healthz
curl -s localhost:8080/healthz

{
  "status": "ok",
  "checks": { "db": "ok", "redis": "ok", "worker": "ok" }
}

Production sizing

Daily volumeAPIWorkersPostgreSQL
Up to 100k records2 × 1 vCPU1 × 2 vCPU2 vCPU · 8 GB
100k – 1M records3 × 2 vCPU3 × 2 vCPU4 vCPU · 16 GB
1M+ recordsAutoscale ≥ 4Autoscale ≥ 68 vCPU · 32 GB + replica
UpgradesAlways run 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 →

Guides / 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.

You'll needTwo connected sources with at least one day of overlapping data, and the integrator role. This guide uses the console; every step has a CLI and API equivalent linked inline.
1

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.

2

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 fieldRight fieldComparison
referenceinvoice_numberNormalized exact
amounttotalNumeric, tolerance below
value_dateposted_atWithin 2 days
3

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%.

rule fragment
"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.

4

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.

Watch the capGroup size above 8 grows search time quickly on large days. If you need larger batches, add a shared batch identifier to both feeds and match on that instead — it's exact and far faster.
5

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.

terminal
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.

6

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.

terminal
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? →

API reference / Breaks

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.

Authorization header
curl https://api.cascade.dev/v1/breaks \
  -H "Authorization: Bearer sk_live_••••••••"
Keep keys server-sideA secret key can read and resolve breaks across your whole organization. Never ship one in a browser, mobile app, or public repository. Rotate immediately in Settings → API keys if one is exposed.

Request conventions

TopicBehavior
Base URLhttps://api.cascade.dev/v1
VersioningPinned in the path. Breaking changes ship under a new version; additive changes do not.
PaginationCursor-based via limit and starting_after. Responses include has_more.
IdempotencySend Idempotency-Key on writes to safely retry without creating duplicates.
Rate limits100 requests/second per key. Throttled requests return 429 with a Retry-After header.

List breaks

GET/v1/breaks

Returns a paginated list of breaks, most recent first. Filter by status, source, or the reconciliation run that produced them.

Query parameters

ParameterTypeDescription
statusstringOne of open, investigating, resolved. Omit to return all.
sourcestringReturn breaks involving this source id, e.g. bank_ach.
reasonstringFilter by break reason, e.g. amount_out_of_tolerance.
limitinteger1–100. Defaults to 20.
starting_afterstringA break id. Returns the page after this object.

Request

curl
curl "https://api.cascade.dev/v1/breaks?status=open&limit=2" \
  -H "Authorization: Bearer sk_live_••••••••"

Response

200 OK · application/json
{
  "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

POST/v1/breaks/:id/resolve

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
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.

EventFires when
break.createdA reconciliation run produces a new break.
break.resolvedA break is resolved, by a person or the API.
run.completedA 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.

StatustypeMeaning
400invalid_requestA parameter is missing or malformed. The param field names it.
401authentication_errorMissing, malformed, or revoked key.
404not_foundNo object with that id in this environment.
409conflictThe break is already resolved. Safe to treat as success.
429rate_limitedSlow down and retry after Retry-After seconds.
Support / Diagnostics

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.

Start hereOpen the break and note its 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.

terminal
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.

About this sample / Workflow

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

1

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.

2

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.

3

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.

4

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 acceleratesThe human owns
First-draft prose from structured inputsInformation architecture and page scope
Example and boilerplate generationTechnical accuracy of every contract detail
Style, terminology, and link QAVoice, judgment, and what to leave out
Drift detection against the specThe decision to publish

The short version: AI made the writing faster; it did not make the writer optional.

About this sample / Notice

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.