# Rateflow Pricing API

> Last verified against API: 2026-04-27

Real-time mortgage rate quotes from any of 7 supported pricing engines through one unified endpoint. For rateflow configuration and engine setup, see `rateflow-schema.md` in the BB repository. For the full OpenAPI spec, see [swagger.json](https://cdn.bankingbridge.com/swagger/swagger.json).

## Read this first — 9 facts that prevent 90% of integration questions

1. **Request body is FLAT.** Send fields like `loan_amount` at the top level. There is no `bb_request` wrapper.
2. **HTTP 200 ≠ success.** Quote failures return 200 with `{"status": "error", "message": "..."}`. Check the response shape: bare array = success, object with `status` = failure.
3. **Use `"ignore_cache": true` while integrating.** Pricing is cached by request hash for 1–6 hours. Without this flag, identical requests return identical cached responses — you cannot tell whether your code change actually did anything.
4. **Send the BASE loan amount for FHA/VA.** The engine computes UFMIP/funding fee and returns the upfront amount in the response `fundingFee` field. Do not add it yourself.
5. **`totalPayment = principalAndInterest + monthlyMI`.** Exact, every time. No MI → `totalPayment = principalAndInterest`.
6. **There is no CLTV field in the response.** Only `ltv` (= `loan_amount / list_price × 100`). If you have a second lien, compute CLTV client-side.
7. **`loan_type: "arm"` is required to get ARM products.** Sending `arm_term` with `loan_type: "conventional"` is silently ignored — you get fixed-rate products back.
8. **`loan_purpose` uses BB-normalized values, not engine UI labels.** Use `"refinance"`, `"cashoutrefinance"`, `"va_refinance"` (= IRRRL), `"fha_refinance"` (= Streamline). See [the full mapping](#loan-purpose-values).
9. **Top-level fields vs. `raw_fields` — don't double-set.** BB translates documented top-level fields (`fees_in`, `cash_out`, `loan_purpose`, etc.) into the underlying engine's native shape for you. Engine-native names sent at the top level (e.g., LoanSifter's `productCharacteristics.feesIn`) are silently ignored. The same names sent inside `raw_fields` silently **override** the corresponding top-level field — that's how `raw_fields` is designed to work, but it's a footgun if you didn't mean to override. `raw_fields` is a legitimate passthrough for engine-native fields BB doesn't expose (investor whitelists, expanded guideline flags, etc.) — see [Engine-Native Overrides via `raw_fields`](#engine-native-overrides-via-raw_fields). For any field BB already documents at the top level, use the top-level field; don't shadow it with a `raw_fields` entry.

## Contents

- [Quick Start](#quick-start)
- [Authentication](#authentication)
- [Rate Limits](#rate-limits)
- [Endpoints Overview](#endpoints-overview)
- [POST /rateflow — Custom Pricing](#post-rateflow--custom-pricing)
- [GET /rateflow — Scenario Pricing](#get-rateflow--scenario-pricing)
- [Examples](#examples)
- [Recipe: Pricing a CRM Export (Batch)](#recipe-pricing-a-crm-export-batch)
- [Rateflow Resolution](#rateflow-resolution)
- [Defaults and Precedence](#defaults-and-precedence)
- [Caching](#caching)
- [Get Pricing Log Entry](#get-pricing-log-entry)
- [Troubleshooting](#troubleshooting)

---

## Quick Start

The smallest working POST — 4 required fields plus a rateflow identifier (`loid`, `nmls`, or `id`). If your API key has a default rateflow, the identifier is optional:

```bash
curl -X POST "https://api.bankingbridge.com/rateflow" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "loid": YOUR_LOID,
    "list_price": 500000,
    "loan_amount": 400000,
    "credit_score": 740,
    "zipcode": "94105"
  }'
```

Everything else defaults: `loan_type` → conventional, `loan_purpose` → purchase, `loan_term` → 30 years, `property_type` → single_family_home, `residency_type` → primary_home, `lock_period` → 60 days.

Response — an array of rate quote cards (first card shown):

```json
[
  {
    "rate": 6.75,
    "apr": 6.755,
    "price": 99.95,
    "principalAndInterest": 2594,
    "monthlyMI": 0,
    "totalPayment": 2594,
    "pts": 0.05,
    "discount": 200,
    "rebate": 0,
    "closingCost": 200,
    "bbClosingCost": 200,
    "fee": { "title": "Discount Points", "amount": 200 },
    "feeList": [],
    "productName": "FNMA Conforming 30 Yr Fixed",
    "productId": "103457158E1777311476-35373303",
    "loanType": "Conventional",
    "bbLoanType": "conventional",
    "amortizationType": "Fixed",
    "amortizationTerm": 30,
    "loanTerm": 30,
    "lockPeriod": 60,
    "ltv": 80,
    "fico": 740,
    "investor": "Keystone Funding, Inc. Wholesale",
    "investorId": "100890",
    "priceStatus": "Available",
    "armIndex": null,
    "armMargin": 0,
    "arm_term": "",
    "lastUpdate": 1777311478,
    "hash": "a3354a5012f15ea42b9c5f1c499f0fe7",
    "comp_val": 1.0163,
    "api_mode": "loan_sifter",
    "quote_id": 52235602
  }
]
```

What happened:
- The system resolved the rateflow configuration from `loid` (the loan officer's default rateflow)
- Defaults filled in `loan_type`, `loan_purpose`, `property_type`, `residency_type`, and `lock_period`
- The response is a **bare array** of rate cards, sorted by competitiveness — not wrapped in an object

---

## Authentication

All requests require an `x-api-key` header. This is an AWS API Gateway key tied to a usage plan.

```bash
curl -H "x-api-key: YOUR_API_KEY" https://api.bankingbridge.com/rateflow?loid=14368
```

Other authentication mechanisms exist on adjacent BankingBridge endpoints but are not accepted on `/rateflow`:

- **bbToken** — used by admin/internal endpoints, not by this API
- **app_key** — only on `GET /rateflow` as a query parameter (see [GET endpoint](#get-rateflow--scenario-pricing))
- **HMAC-signed init tokens** — used by the anonymous chat surface, not by this API

If your API key is rejected with `401 you are not allowed to do that`, the most likely causes are: invalid/expired key, key not associated with a usage plan, or the loan officer (`loid`) referenced in the request does not belong to the brand your key is scoped to.

---

## Rate Limits

Rate limits are set per API key via AWS usage plans. The exact numbers for your key are listed in the AWS Console / usage plan you were assigned — verify with your BB account team rather than relying on the table below.

| Plan | Steady Rate | Burst | Monthly Quota |
|------|------------|-------|---------------|
| Enterprise | 100 req/sec | 1,000 | 75,000 |
| Enterprise Plus | 100 req/sec | 1,000 | 250,000 |

> The Enterprise and Enterprise Plus tiers share the same per-second rate and burst here — they differ only in monthly quota. If you need a higher request rate, ask about a custom plan.

Exceeding the rate or burst limit returns HTTP 429. Exceeding the monthly quota returns HTTP 403. For batch workloads, keep concurrency under 50 parallel requests to stay within the burst limit with margin.

---

## Endpoints Overview

| Method | Path | Purpose | Status |
|--------|------|---------|--------|
| **POST** | `/rateflow` | Price a custom loan scenario | Active — use for all new integrations |
| **GET** | `/rateflow` | Get pre-configured scenario pricing | Active — different response shape from POST |
| GET | `/rateflow/{rateflow_id}/log?id={quote_id}` | Get the full request/response log for a previous quote (path id is for routing only — pass `quote_id` in `?id=`) | Active |
| GET, POST | `/rateflow/{id}/price` | Custom or scenario pricing for a specific rateflow | **Legacy — replaced by `/rateflow` with `"id": {id}` in body or query.** Do not use for new integrations |

The legacy `/price` endpoints remain functional for backward compatibility but are not receiving new features (e.g., Non-QM auto-injection guarantees apply to `/rateflow` only).

---

## POST /rateflow — Custom Pricing

Send a loan scenario and get back rate quotes from the configured pricing engine.

### Request Fields

All fields go at the top level of the JSON body. There is no `bb_request` wrapper.

**Required:** `list_price`, `loan_amount` (or `down_payment`), `credit_score`, `zipcode`, plus one rateflow identifier (`id`, `loid`, or `nmls`). The identifier can be omitted when the API key has a default rateflow.

| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `arm_term` | integer | — | ARM initial fixed period (5, 7, 10). Requires `loan_type: "arm"` — ignored otherwise |
| `aus` | string | (rateflow config) | Automated Underwriting System filter — restricts the rate pool to products eligible under the selected AUS. Common values: `"DU"`, `"LP"`, `"GUS"`, `"NotSpecified"` (default = no filter). See [AUS Values](#aus-values). Aliases: `brokeraus`, `useaus`, `use_aus` |
| `cash_out` | number | 0 | Actual dollar amount of cash-out (e.g., `50000`). Any value `> 0` also triggers cash-out pricing automatically — no need to also change `loan_purpose`. **Do not use `1` as a flag** — the engine prices it as a $1 cash-out and the resulting rates will not match a direct-engine comparison that used a real cash-out amount |
| `county` | string | — | Property county |
| `credit_score` | integer | — | Required. Aliases: `fico`, `min_credit`, `creditscore` |
| `debt_to_income` | number | — | DTI as decimal (e.g., 0.35). Computed from `monthly_income`/`monthly_debt` if both provided |
| `down_payment` | number | — | Alternative to `loan_amount`. If both are sent, `loan_amount` wins |
| `fees_in` | boolean | true | Lender-paid compensation (LPC) when `true`, borrower-paid (BPC) when `false`. Same lender pool, opposite sign conventions on points/credits. See [Compensation Models](#compensation-models--lpc-vs-bpc) for a side-by-side example. **Do not also send `raw_fields["productCharacteristics.feesIn"]` or any engine-native equivalent** — BB translates `fees_in` into the engine's native field for you |
| `first_time_homebuyer` | boolean | false | On Optimal Blue: adds `"AffordableProducts"` (HomeReady, Home Possible). No-op on other engines |
| `include_affordable_products` | boolean | false | Adds `"AffordableProducts"` (Home Possible, HomeOne) on both Optimal Blue and LoanSifter. Request-level override — takes precedence over the rateflow setting of the same name |
| `funding_fees_in` | boolean | true | Roll FHA UFMIP / VA funding fee into the loan amount. Independent of `fees_in` |
| `id` | integer | — | Rateflow ID — admin keys only |
| `ignore_cache` | boolean | false | Bypass cache. **Use during integration** — see fact #3 above |
| `list_price` | number | — | Required. Property value / appraised value |
| `loan_amount` | number | — | Required (or `down_payment`). For refinance, this is the current balance |
| `loan_purpose` | string | `"purchase"` | See [Loan Purpose Values](#loan-purpose-values) |
| `loan_term` | integer | 30 | Years |
| `loan_type` | string | `"conventional"` | One of: `"conventional"`, `"fha"`, `"va"`, `"usda"`, `"jumbo"`, `"arm"`, `"arm_va"`, `"arm_fha"`, `"arm_usda"`, `"nonqm"` |
| `location` | object | — | `{"zipcode", "state", "city", "county"}` — alternative to flat location fields. Flat fields win when both are sent |
| `lock_period` | integer | 60 | Days. Accepted: 15, 30, 45, 60, 90 (engine-dependent) |
| `loid` | integer | API key owner | Loan officer ID — resolves their default rateflow |
| `military_eligible` | string \| boolean | — | VA eligibility. `"yes"` / `"no"` or `true` / `false` |
| `monthly_debt` | number | — | Monthly debt obligations in dollars |
| `monthly_income` | number | 0 (4000 for FTHB) | Monthly gross income in dollars |
| `nmls` | integer | — | LO NMLS — resolved via lookup |
| `nonqm_program` | string | — | Required when `loan_type: "nonqm"`. See [Non-QM Fields](#non-qm-fields) |
| `pricing_set_id` | integer | rateflow default | Override which pricing set is used |
| `property_type` | string | `"single_family_home"` | `"condo"`, `"townhome"`, `"home_2_units"` ... `"home_4_units"`, `"manufactured"`, `"coop"`, `"condotel"`, `"pud"`, `"land_only"` |
| `raw_fields` | object | — | Engine-native field passthrough, merged into the request BB sends to the engine. Auto-populated by `nonqm_program`; also available for callers who know the underlying engine's API and need to set fields BB doesn't expose at the top level (investor whitelists, expanded guideline flags, product-type filters, etc.) or override a Non-QM auto-injected default. **Keys here win over top-level fields** — if you set both `fees_in` and `raw_fields["productCharacteristics.feesIn"]`, the `raw_fields` value silently overrides. See [Engine-Native Overrides via `raw_fields`](#engine-native-overrides-via-raw_fields) |
| `residency_type` | string | `"primary_home"` | `"primary_home"`, `"second_home"`, `"rental_home"` |
| `state` | string | — | 2-letter state code |
| `target_price` | number | 100 (par) | Per-request override of the rateflow's configured target price |
| `va_funding_fee_type` | string | `"first_time"` | `"first_time"`, `"second_time"`, `"exempt"`, `"done"`. See [VA funding fee variants](#va-funding-fee-variants) |
| `zipcode` | string | — | Required. Property zip code. **Send as a JSON string to preserve leading zeros** — see [Type Handling](#type-handling) |

#### Type Handling

**BB's top-level fields are forgiving about types.** BB coerces every documented top-level field before handing it to the engine, so JSON strings and JSON numbers are generally interchangeable on the inbound side:

- **Numeric fields** (`list_price`, `loan_amount`, `cash_out`, `credit_score`, `monthly_income`, `monthly_debt`, `target_price`, `loan_term`, `arm_term`, `lock_period`, etc.) — either a JSON number (`400000`) or a numeric string (`"400000"`) works. BB coerces with `floatval()` / `intval()`.
- **Booleans** (`fees_in`, `funding_fees_in`, `first_time_homebuyer`, `include_affordable_products`, `ignore_cache`, `military_eligible`, etc.) — BB accepts `true`/`false`, `1`/`0`, `"1"`/`"0"`, `"true"`/`"false"`, `"yes"`/`"no"`, `"on"`/`"off"`, `"y"`/`"n"`, and any string starting with `t` or `y`. Prefer JSON `true` / `false` for clarity.
- **String enums** (`loan_type`, `loan_purpose`, `aus`, `nonqm_program`, `property_type`, `residency_type`, `va_funding_fee_type`) — case-insensitive, with per-field alias tables documented above. Send the canonical lowercase form when possible.
- **LTV-shaped numerics** (`debt_to_income`) — BB auto-divides by 100 if the value is > 1, so `0.35` and `35` both mean 35%.
- **`credit_score`** — values > 999 have trailing digits stripped to a 3-digit number; values < 400 are treated as missing. Either `740` or `"740"` works.

**Hard requirements where types matter:**

1. **`zipcode` must be a JSON string when it has a leading zero.** New England and a handful of other regions use zips like `"02110"`, `"01005"`, `"00501"` — sending these as JSON numbers strips the leading zero (`2110`) and the resulting value resolves to the wrong county or fails validation entirely. **Always send `zipcode` as a string.** This is the single most common type bug we see.
2. **`raw_fields` values pass through verbatim — BB does not coerce them.** Each engine has its own type expectations, and what BB does for translated top-level fields does NOT apply to keys you set inside `raw_fields`:
   - **Optimal Blue** generally expects **strings** for all guideline values, including numeric ones — `"125"` not `125` for `debtServiceCoverageRatio`, `"FiveYear"` not an enum number for `prepaymentPenalty`. BB's own Non-QM auto-injection registry uses string values throughout, which is the safest pattern to mirror.
   - **LoanSifter** is more permissive on scalars but specific fields require specific shapes — e.g., `productCharacteristics.productTypes` is an **array** (`["AffordableProducts"]`), and several boolean flags want **lowercase strings** (`"true"` / `"false"`) rather than JSON booleans.
   - **Other engines** (`polly`, `mortech`, `lender_price`, `loanpass`, `mortgage_bot`) — consult that engine's own API documentation.

   When unsure, mirror the type shape from the [Engine-Native Overrides examples](#engine-native-overrides-via-raw_fields) and verify by reading the outbound request via [`GET /rateflow/{id}/log`](#get-pricing-log-entry) — the log shows the exact final payload BB sent.
3. **Response types differ between POST and GET.** POST returns `rate` / `apr` / `price` as JSON **numbers** (e.g., `6.75`). GET returns the same fields as JSON **strings** (e.g., `"6.125"`). Parsers that work for one endpoint do not work for the other unless they coerce on the client side — see [GET response differences](#response-format-1).

#### Loan Purpose Values

| BB API value | When to use | LoanSifter label | Optimal Blue label |
|--------------|-------------|------------------|--------------------|
| `"purchase"` | Default | Purchase | Purchase |
| `"refinance"` | Rate-and-term refi (any loan type) | Rate/Term Refinance | NoCashOutRefinance |
| `"cashoutrefinance"` | Cash-out refi (any loan type). Equivalent to `"refinance"` + `cash_out > 0` | Refi Cash-Out | CashOutRefinance |
| `"va_refinance"` | VA IRRRL | IRRRL | VAIRRRL |
| `"fha_refinance"` | FHA Streamline | Streamline Refinance | FHAStreamlineRefinance |
| `"usda_refinance"` | USDA rate-and-term refi | Rate/Term Refinance | NoCashOutRefinance |
| `"construction"` | New construction | — | Construction |
| `"construction_permit"` | Construction-to-perm | — | ConstructionPerm |

Always send the BB API value, never the engine UI label.

#### AUS Values

The `aus` field restricts the rate pool to products eligible under the selected Automated Underwriting System. Selecting the wrong AUS — or letting the rateflow's default win unintentionally — is a common cause of "BB pricing doesn't match my AUS findings" and "DU and LP runs return the same pool."

| BB API value | AUS | When to use |
|--------------|-----|-------------|
| `"DU"` | Fannie Mae Desktop Underwriter | Conventional loans you'll deliver to Fannie / route through DU |
| `"LP"` | Freddie Mac Loan Product Advisor (aka LPA) | Conventional loans you'll deliver to Freddie / route through LPA |
| `"GUS"` | USDA Guaranteed Underwriting System | USDA loans |
| `"invest"` | Investor AUS | Investor-specific AUS routing |
| `"manual"` | Manual underwriting | Manually underwritten loans (jumbo, Non-QM, exception files) |
| `"NotSpecified"` | — | Default — no AUS filter applied |
| `"none"` | — | Explicitly send no AUS |
| `"any"` | — | Match products eligible under any AUS |
| `"all"` | — | Match products eligible under all AUSes |

**Caller aliases.** BB normalizes many input forms to the canonical values above before sending to the engine — e.g., `du`, `aus_du`, `du_aus`, `ausdu`, `duaus`, `fannie-mae-du` all become `"DU"`; the same pattern applies to LP, GUS, investor, and manual. Sending the canonical value is preferred but the aliases are accepted.

**Default behavior when `aus` is omitted.** The system falls back to the rateflow's configured AUS preference — the Optimal Blue profile's `ob_aus` setting on OB-backed rateflows, or LoanSifter's `any` default on LS-backed rateflows. If a brand's rateflow is pinned to DU but a request expects LP results, the pinned value wins unless `aus` is sent explicitly on the request. **When AUS routing matters to your output, send `aus` on every request — don't rely on the rateflow default.**

**Engine support.** The `aus` field is read by adapters for `optimal_blue`, `loan_sifter`, `polly`, `lender_price`, `loanpass`, and `mortgage_bot`. The `mortech` adapter does not currently use `aus` — passing it is silently ignored on Mortech-backed rateflows. Per-engine value coverage varies (e.g., not every engine recognizes `GUS` or `invest`) — verify against the engine + brand combination you're targeting and use [`GET /rateflow/{id}/log?id={quote_id}`](#get-pricing-log-entry) to inspect what was actually sent.

#### VA Funding Fee Type Values

`va_funding_fee_type`: `"first_time"` | `"second_time"` | `"exempt"` | `"done"`

- `"first_time"` — first use of VA loan benefit (lower funding fee). On first use the fee also varies with down-payment percentage
- `"second_time"` — subsequent use of VA loan benefit (higher, generally flat-rate funding fee)
- `"exempt"` — veteran is exempt from funding fee (e.g., disability rating of 10%+). `fundingFee` will be 0
- `"done"` — funding fee already paid (e.g., financed separately or rolled into a prior closing). Treated as exempt for pricing purposes

#### Non-QM Fields

> **Important:** When `loan_type` is `"nonqm"`, the system automatically injects all required engine-specific fields (`raw_fields`) for the selected program — income verification type, DSCR ratio, prepayment penalty, etc. You only need to set `nonqm_program`. If you want to override an injected default (e.g., a different DSCR ratio) or add an extra engine-native flag the registry doesn't set, pass it in `raw_fields` — your keys merge over the registry on a key-by-key basis. See [Engine-Native Overrides via `raw_fields`](#engine-native-overrides-via-raw_fields).

Set `loan_type` to `"nonqm"` and provide `nonqm_program`:

| Program | `nonqm_program` | Income Verification Type | Prepayment Penalty (Purchase) | Prepayment Penalty (Refinance) | Auto-set Defaults |
|---------|-----------------|--------------------------|-------------------------------|--------------------------------|-------------------|
| Investor DSCR | `"dscr"` | InvestorDscr | None | FiveYear | `residency_type` → `"rental_home"` |
| Bank Statement 12mo | `"bank_stmt_12mo"` | PersonalBankStmt12Mos | None | FiveYear | — |
| Bank Statement 24mo | `"bank_stmt_24mo"` | PersonalBankStmt24Mos | None | FiveYear | — |
| Business Bank Stmt 12mo | `"biz_bank_stmt_12mo"` | BusinessBankStmt12Mos | None | FiveYear | — |
| Business Bank Stmt 24mo | `"biz_bank_stmt_24mo"` | BusinessBankStmt24Mos | None | FiveYear | — |
| 2-Year Alt Doc | `"alt_doc"` | TwoYearAltDoc | None | None | — |
| Asset Qualifier | `"asset_qualifier"` | AssetDepletion | None | None | — |
| Full Doc Non-QM | `"full_doc"` | FullDoc | None | None | — |

**How auto-injection works:**

1. The system looks up the `nonqm_program` in an internal registry
2. Engine-specific `raw_fields` are injected into the request (e.g., `loanInformation.expandedGuidelines.incomeVerificationType` for Optimal Blue)
3. If `loan_purpose` is `"refinance"`, additional refinance-specific raw_fields are merged (e.g., prepayment penalty)
4. Program defaults are applied to any empty fields (e.g., DSCR forces `residency_type` to `"rental_home"`)
5. The engine receives `productTypes: ["nonQM"]` to search non-QM investor products

**DSCR-specific behavior:**
- DSCR sets `debtServiceCoverageRatio` to `125` (1.25x) by default on Optimal Blue
- On purchase: `prepaymentPenalty` = `"None"`
- On refinance: `prepaymentPenalty` = `"FiveYear"` (5-year prepay penalty)
- `residency_type` is forced to `"rental_home"` regardless of what you pass

**Bank statement programs (all 4 variants):**
- No prepayment penalty on purchase
- FiveYear prepayment penalty on refinance
- No default overrides — `residency_type` defaults to `"primary_home"` unless you specify otherwise

**Alt Doc / Asset Qualifier / Full Doc:**
- No prepayment penalty on either purchase or refinance
- No default overrides

**Engine support — read this carefully:**

Non-QM auto-injection is validated and supported on:

- **Optimal Blue** (`api_mode: "optimal_blue"`) — full coverage of all programs in the table above
- **Loan Sifter** (`api_mode: "loan_sifter"`) — coverage of all programs above

For all other engines (`polly`, `mortech`, `lender_price`, `loanpass`, `mortgage_bot`), the system injects Optimal Blue-shaped `raw_fields` as a best-effort fallback. The target engine generally does not recognize those field names and will either return zero eligible products or silently ignore the program selection. **Treat Non-QM on these engines as unsupported** until you have confirmed working quotes for your specific brand + program combination — file a finding if you need coverage added.

#### Engine-Native Overrides via `raw_fields`

`raw_fields` is a passthrough for engine-native field names. Anything you put here is merged into the request BB sends to the underlying pricing engine, and it takes precedence over BB's documented top-level fields when the keys would translate to the same engine field. Two legitimate uses:

1. **Setting a field BB doesn't expose at the top level.** Investor whitelists, product-type filters, expanded guideline flags, custom MI provider, second-lien details, lock-day fine-tuning, etc. If the field exists in the engine's own API, you can send it via `raw_fields` even if BB has no documented wrapper for it.
2. **Overriding a Non-QM auto-injected default.** E.g., the DSCR registry sets `debtServiceCoverageRatio` to `125` (1.25x) — pass your own value in `raw_fields` to override it. The registry merges first; your `raw_fields` win on a key-by-key basis (not a whole-object replace).

**Key shape matches the engine, not BB.** BB does **not** transform, validate, or rename the keys you put in `raw_fields` — they pass through verbatim to the engine, which will either accept them or reject the request. There are too many fields across the seven supported engines to enumerate here; consult the engine's own API documentation (or your BB account team) for the field names and value formats. Two example shapes:

| Engine (`api_mode`) | Typical key shape | Example |
|---------------------|-------------------|---------|
| `optimal_blue` | Dotted path, often under `loanInformation` or top-level OB groups | `"loanInformation.expandedGuidelines.debtServiceCoverageRatio": "100"` |
| `loan_sifter` | Dotted path with engine-native groups | `"productCharacteristics.productTypes": ["AffordableProducts"]` |
| `polly`, `mortech`, `lender_price`, `loanpass`, `mortgage_bot` | Engine-specific — see that engine's API docs | — |

**Example — passing a LoanSifter product-type filter:**

```bash
curl -X POST "https://api.bankingbridge.com/rateflow" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "loid": YOUR_LOID,
    "loan_type": "conventional",
    "list_price": 350000,
    "loan_amount": 332500,
    "credit_score": 700,
    "zipcode": "33444",
    "raw_fields": {
      "productCharacteristics.productTypes": ["AffordableProducts"]
    }
  }'
```

**Example — overriding the Non-QM DSCR ratio on Optimal Blue:**

```bash
curl -X POST "https://api.bankingbridge.com/rateflow" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "loid": YOUR_LOID,
    "loan_type": "nonqm",
    "nonqm_program": "dscr",
    "list_price": 700000,
    "loan_amount": 500000,
    "credit_score": 740,
    "zipcode": "91001",
    "raw_fields": {
      "loanInformation.expandedGuidelines.debtServiceCoverageRatio": "100"
    }
  }'
```

**When NOT to use `raw_fields`:**

- For any field BB already documents at the top level (`fees_in`, `cash_out`, `loan_purpose`, `loan_type`, `credit_score`, `residency_type`, etc.) — use the top-level field. A `raw_fields` entry for the same logical field will silently override the top-level value and is the most common cause of "BB ignored my toggle."
- As a workaround for fields that don't exist in the engine's own API. BB does not invent fields — if the engine doesn't recognize the key, it's either ignored or the request fails validation.

**Debugging:** Fetch the actual outbound request BB sent to the engine via `GET /rateflow/{rateflow_id}/log?id={quote_id}` — that's the authoritative view of what the engine received, including any merged `raw_fields`.

### Response Format

Successful POST responses are a bare JSON array of rate cards. Error responses are a single object: `{"status": "error", "message": "..."}`. Always check the shape before indexing.

Field names mix camelCase, snake_case, and short ad-hoc names. Use the literal names below — do not infer variants. The GET endpoint uses a different naming convention; see [GET response](#response-format-1).

| Field | Type | Notes |
|-------|------|-------|
| `amortizationTerm` | number | Years |
| `amortizationType` | string | `"Fixed"` or `"Adjustable"` |
| `api_mode` | string | Pricing engine used (`"loan_sifter"`, `"optimal_blue"`, etc.) |
| `apr` | number | APR including closing costs |
| `arm_term` | string | `"7/1"` etc., or empty string for fixed |
| `armIndex` | number \| null | null for fixed |
| `armMargin` | number | 0 for fixed |
| `bbClosingCost` | number | BB-calculated closing cost. Negative = net lender credit to borrower |
| `bbLoanType` | string | BB-normalized loan type — echoes your input |
| `closingCost` | number | Total closing costs in dollars |
| `comp_val` | number | Internal competitiveness score (used for sorting; ignore in clients) |
| `discount` | number | Discount points cost in dollars (0 when there's a credit) |
| `fee` | object | Primary fee: `{"title": "Discount Points", "amount": 200}` or `{"title": "Lender Credit", "amount": 984}` |
| `feeList` | array | Additional itemized fees: `[{"label": "Lender Fees", "amount": 104, "type": "Lender"}]` |
| `fico` | number | Credit score used |
| `fundingFee` | number | Upfront FHA UFMIP or VA funding fee in dollars. **Do not add to your `loan_amount`** — the engine handles this |
| `hash` | string | Unique hash for this rate option |
| `investor` | string | Lender name (e.g., `"Cardinal Financial Company..."`) |
| `investorId` | string | Investor ID in the pricing engine |
| `lastUpdate` | integer | Unix timestamp from the engine |
| `loanTerm` | number | Years |
| `loanType` | string | Engine-formatted (`"Conventional"`, `"FHA"`, `"VA"`) |
| `lockPeriod` | number | Days |
| `ltv` | number | Loan-to-value, e.g., 80. **No `cltv` is returned** — compute client-side if you have a second lien |
| `monthlyMI` | number | Monthly MI premium. 0 when no MI |
| `price` | number | Pricing points. 100 = par; >100 = lender credit; <100 = discount cost |
| `priceStatus` | string | `"Available"` when valid |
| `principalAndInterest` | number | Monthly P&I in dollars |
| `productId` | string | Engine-specific product identifier |
| `productName` | string | Full product name (e.g., `"FNMA Conforming 30 Yr Fixed"`) |
| `pts` | number | Positive = cost to borrower (discount); negative = lender credit |
| `quote_id` | integer | Use with `GET /rateflow/{id}/log?id={quote_id}` to fetch the full log |
| `rate` | number | Base interest rate |
| `rebate` | number | Lender credit/rebate in dollars (0 when there's a discount cost) |
| `totalPayment` | number | `principalAndInterest + monthlyMI`, always |

**Not echoed by POST:** `cltv`, `loan_amount`, `list_price`, `zipcode`, `property_type`, `residency_type`. Track your inputs client-side; correlate by `quote_id`.

#### Lender-credit response shape

When `fees_in: false` or the rateflow target produces a credit, `pts` and `closingCost` go negative, `rebate` populates, and `fee.title` flips to `"Lender Credit"`. Same shape, opposite signs.

```json
{ "rate": 7.25, "price": 100.45, "pts": -0.45,
  "discount": 0, "rebate": 984, "closingCost": -984,
  "fee": { "title": "Lender Credit", "amount": 984 } }
```

### Detecting Failure

> **The `/rateflow` endpoint returns HTTP 200 for most quote failures.** A successful request can still be a failed quote. Clients that only inspect HTTP status will silently treat failures as successes.

A POST response has three possible shapes — check the shape first, then the contents:

```javascript
const body = await res.json();

if (Array.isArray(body)) {
  if (body.length === 0) {
    // No eligible products. Likely LTV / FICO / target_price issue.
  } else {
    // Success — body[0] is the best card.
  }
} else if (body && body.status === "error") {
  // Engine, validation, or auth failure — inspect body.message.
} else {
  // Unexpected shape — log and treat as a transient error.
}
```

### Error Responses

Most errors return HTTP 200 with `{"status": "error", "message": "..."}`. Network/auth/rate errors use real HTTP status codes.

| HTTP Status | Message | Meaning |
|-------------|---------|---------|
| 200 | `{"status":"error","message":"quote failed - no valid quote id"}` | Pricing engine returned no eligible products (see [Troubleshooting](#troubleshooting)) |
| 200 | `{"status":"error","message":"quote failed - disabled"}` | Rateflow is disabled in its configuration |
| 200 | `{"status":"error","message":"quote failed - missing rateflow id"}` | Could not resolve a rateflow — provide `loid` or ensure API key owner has a default |
| 200 | `{"status":"error","message":"could not find rateflow with id : "}` | Rateflow ID not found in database |
| 200 | `{"status":"error","message":"quote failed - V010 - ..."}` | Engine validation error (e.g., missing address/zipcode). Other `V0xx` codes indicate field-level rejections — see [PricingEngineRequest.php](https://cdn.bankingbridge.com/swagger/swagger.json) error codes |
| 200 | `[]` (empty array) | Engine returned results but all were filtered (target_price, eligibility) — adjust scenario |
| 401 | `{"status":"error","message":"you are not allowed to do that"}` | Authentication failed, key is unrecognized, or the `loid` does not belong to your brand |
| 403 | (varies) | Monthly quota exceeded |
| 429 | `{"message":"Too Many Requests"}` | Per-second rate or burst limit exceeded — back off and retry |
| 5xx | (engine-dependent) | Upstream pricing engine outage. Safe to retry after backoff |

---

## GET /rateflow — Scenario Pricing

Returns pre-configured scenario quotes based on the rateflow's pricing profiles. Unlike POST, you don't specify loan parameters — the system uses the profiles configured on the rateflow.

### Query Parameters

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `loid` | integer | (*) | | Loan officer ID |
| `email` | string | (*) | | Loan officer email |
| `nmls` | integer | (*) | | Loan officer NMLS |
| `app_key` | string | (*) | | Integration app key |
| `max_results` | integer | | 6 | Maximum number of scenarios |
| `include_trend` | boolean | | true | Include rate trend data |
| `include_assumptions` | boolean | | false | Include loan assumptions |
| `ignore_cache` | boolean | | false | Force fresh pricing |
| `list_price` | number | | | Override property value |
| `min_credit` | integer | | | Override credit score |
| `pricing_set_id` | integer | | | Override pricing set |
| `target_price` | number | | | Override target price |
| `zipcode` | string | | | Override property location |

(*) At least one identifier is needed (or API key must have a default rateflow).

### Response Format

GET wraps results in `{"results": [...]}`. The card shape differs from POST — GET returns BankingBridge-normalized fields:

```json
{
  "results": [
    {
      "rate": "6.125",
      "apr": "6.337",
      "price": "99.895",
      "pts": 0.105,
      "pi_monthly": 1551.69,
      "mi": 0,
      "umip": 0.0215,
      "fee": { "title": "Discount Points", "amount": 268 },
      "closing_cost": 276,
      "label": "VA 0% DOWN",
      "term": 360,
      "list_price": 250000,
      "loan_amount": 250000,
      "dpp": 0,
      "zipcode": "91001",
      "city": "Altadena",
      "county": "Los Angeles",
      "state": "CA",
      "national_average": 5.75,
      "profile_preference": "va_zdp",
      "tag": "30p_va_ldp_cs6",
      "pricing_tpl_id": 2041640,
      "rateflow_log_id": 52235621
    }
  ]
}
```

**Key differences from POST response:**
- Wrapped in `{"results": [...]}` rather than a bare array
- Uses `pi_monthly` instead of `principalAndInterest`
- Includes location fields (`zipcode`, `city`, `county`, `state`)
- Includes `list_price`, `loan_amount`, `dpp` (down payment percentage)
- Includes `label`, `national_average`, `profile_preference`, `tag`
- **`rate`, `apr`, and `price` are strings (not numbers)** — POST returns these as numbers. Parsers that work for one endpoint will not work for the other unless they coerce
- Does not include `investor`, `investorId`, `productName`, `productId`

The two response shapes are historical (GET predates POST). For new client code, prefer POST unless you specifically need the pre-configured scenario behavior.

---

## Examples

All examples use `YOUR_API_KEY` and `YOUR_LOID` as placeholders.

### Canonical Purchase (Conventional, 20% down)

This is the reference example — every other example below is a delta against this one.

```bash
curl -X POST "https://api.bankingbridge.com/rateflow" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "loid": YOUR_LOID,
    "loan_type": "conventional",
    "loan_purpose": "purchase",
    "list_price": 500000,
    "loan_amount": 400000,
    "credit_score": 740,
    "loan_term": 30,
    "zipcode": "94105",
    "property_type": "single_family_home",
    "residency_type": "primary_home"
  }'
```

- 20% down → no PMI (`monthlyMI` = 0)
- Loan amount must be within conforming limits for the county

### Loan-type variants

Each row below is a **delta on the canonical request** above — change only the listed fields. Numeric values are illustrative.

| Scenario | Changes to the canonical body | Notes |
|----------|-------------------------------|-------|
| Conventional refinance | `loan_purpose: "refinance"` | `list_price` is current appraised value; `loan_amount` is current balance |
| Cash-out refinance | `loan_purpose: "refinance"`, add `cash_out: 50000` | `cash_out > 0` triggers cash-out pricing automatically. Higher rates than rate-and-term |
| Jumbo purchase | `loan_type: "jumbo"`, `list_price: 1200000`, `loan_amount: 900000`, `credit_score: 760` | Jumbo thresholds vary by county; 720+ FICO typical |
| FHA purchase | `loan_type: "fha"`, `list_price: 350000`, `loan_amount: 337750`, `credit_score: 680` | 3.5% minimum down (96.5% LTV). Response includes `monthlyMI` and `fundingFee` (upfront MIP) |
| VA purchase (zero down) | `loan_type: "va"`, `list_price: 450000`, `loan_amount: 450000`, add `military_eligible: "yes"` | 100% financing, no monthly MI. VA funding fee appears in `fundingFee` |
| First-time homebuyer | add `first_time_homebuyer: true`, `loan_amount: 332500`, `credit_score: 700` | On Optimal Blue: adds `"AffordableProducts"` (HomeReady, Home Possible). No-op on other engines |
| Affordable products | add `include_affordable_products: true` | Adds `"AffordableProducts"` (Home Possible, HomeOne) on both OB and LoanSifter. Per-request override — does not require the rateflow setting |
| 7/1 ARM | `loan_type: "arm"`, add `arm_term: 7` | Response `arm_term` shows `"7/1"`; `amortizationType: "Adjustable"`; `armIndex` and `armMargin` populate. `loanTerm` is still 30 |

### VA funding fee variants

VA loans share the canonical shape with `loan_type: "va"` + `military_eligible: "yes"`. The funding fee is driven by `va_funding_fee_type`:

| Scenario | Add to VA request | Resulting `fundingFee` |
|----------|-------------------|------------------------|
| First use (default) | nothing (or `va_funding_fee_type: "first_time"`) | Lower fee; varies with down-payment % |
| Subsequent use | `va_funding_fee_type: "second_time"` | Higher fee; generally flat-rate |
| Exempt | `va_funding_fee_type: "exempt"` | 0 (disability rating 10%+, surviving spouses, Purple Heart) |
| Already paid / financed separately | `va_funding_fee_type: "done"` | Treated as exempt |

### Compensation Models — LPC vs BPC

The same rateflow can be priced as either lender-paid (LPC) or borrower-paid (BPC) compensation. Send only the top-level `fees_in` boolean — BB translates this into the engine's native field. Do not also send `raw_fields` or engine-native field names like `productCharacteristics.feesIn`; doing so will silently override `fees_in`.

**Lender-paid (LPC, the default):**

```bash
curl -X POST "https://api.bankingbridge.com/rateflow" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "loid": YOUR_LOID,
    "loan_type": "conventional",
    "loan_purpose": "cashoutrefinance",
    "list_price": 465101,
    "loan_amount": 326633,
    "cash_out": 50000,
    "credit_score": 700,
    "loan_term": 30,
    "lock_period": 30,
    "zipcode": "33444",
    "fees_in": true,
    "ignore_cache": true
  }'
```

**Borrower-paid (BPC):** same body, change `fees_in` to `false`. Everything else stays identical.

The two responses pull from the same lender pool but with inverted sign conventions: BPC pushes `pts` and `closingCost` negative, populates `rebate`, and flips `fee.title` to `"Lender Credit"`. See [Lender-credit response shape](#lender-credit-response-shape).

**If LPC and BPC return identical rate pools, that's a bug — not expected behavior.** See [Troubleshooting → LPC and BPC look identical](#lpc-and-bpc-look-identical).

**Common anti-patterns that produce confusing comparisons:**

- Sending `raw_fields["productCharacteristics.feesIn"]` alongside top-level `fees_in`. The `raw_fields` entry silently wins.
- Sending `productCharacteristics: { feesIn: false }` at the top level. Unknown top-level fields are ignored.
- Sending `cash_out: 1` as a flag. The engine prices it as $1 of cash-out — you cannot compare that against a direct-engine run that used a real cash-out dollar amount.
- Sending `raw_defaults` (not a documented field — silently ignored).

### Non-QM examples

Non-QM requests share the same base shape — change `nonqm_program` and let auto-injection handle engine-specific raw fields. Reminder: only Optimal Blue and Loan Sifter are fully supported; see [Non-QM Fields](#non-qm-fields).

```bash
curl -X POST "https://api.bankingbridge.com/rateflow" \
  -H "x-api-key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "loid": YOUR_LOID,
    "loan_type": "nonqm",
    "nonqm_program": "dscr",
    "loan_purpose": "purchase",
    "list_price": 700000,
    "loan_amount": 500000,
    "credit_score": 740,
    "loan_term": 30,
    "zipcode": "91001",
    "property_type": "single_family_home"
  }'
```

| `nonqm_program` | Scenario | Notes |
|-----------------|----------|-------|
| `"dscr"` | DSCR purchase | `residency_type` auto-set to `"rental_home"`. No prepayment penalty |
| `"dscr"` + `loan_purpose: "refinance"` | DSCR refinance | 5-year prepayment penalty applied automatically |
| `"bank_stmt_12mo"` | Bank statement (personal, 12 mo) | Self-employed; personal bank statements |
| `"bank_stmt_24mo"` | Bank statement (personal, 24 mo) | Same, longer verification window |
| `"biz_bank_stmt_12mo"` | Business bank stmt (12 mo) | Business owner; business statements |
| `"biz_bank_stmt_24mo"` | Business bank stmt (24 mo) | Same, longer verification window |
| `"alt_doc"` | 2-Year Alt Doc | Alternative documentation income |
| `"asset_qualifier"` | Asset Qualifier | Liquid assets in lieu of income |
| `"full_doc"` | Full Doc Non-QM | Full doc, on Non-QM rails |

---

---

## Recipe: Pricing a CRM Export (Batch)

When pricing a list of leads or loans from a CRM export:

**Latency budget.** A single quote typically completes in 1-3 seconds for conventional/FHA/VA on Optimal Blue or LoanSifter. Non-QM and cold-cache requests can take 10-15 seconds. Set a per-request timeout of at least 30 seconds and retry on transient 5xx or timeout — but **never retry on 200 + `status: error`** (that's a deterministic rejection, not a transient failure).

**Correlation.** The API does not echo a client-supplied `client_ref`. Correlate inputs to outputs by attaching your lead object to the result (as the examples below do) or by storing the `quote_id` from the first response card for later log lookup via `GET /rateflow/{id}/log`.

### Node.js

```javascript
const API_URL = "https://api.bankingbridge.com/rateflow";
const API_KEY = "YOUR_API_KEY";
const CONCURRENCY = 20; // stay well under 100 req/sec burst
const TIMEOUT_MS = 30000;

async function priceOne(lead) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
  try {
    const res = await fetch(API_URL, {
      method: "POST",
      headers: { "x-api-key": API_KEY, "Content-Type": "application/json" },
      body: JSON.stringify({
        loid: lead.loid,
        list_price: lead.home_value,
        loan_amount: lead.loan_amount,
        credit_score: lead.credit_score,
        zipcode: lead.zipcode,
        loan_type: lead.loan_type || "conventional",
        loan_purpose: lead.loan_purpose || "purchase",
      }),
      signal: controller.signal,
    });
    const body = await res.json();
    return { lead, quotes: body };
  } finally {
    clearTimeout(timeout);
  }
}

// Process in batches
async function priceBatch(leads) {
  const results = [];
  for (let i = 0; i < leads.length; i += CONCURRENCY) {
    const batch = leads.slice(i, i + CONCURRENCY);
    const batchResults = await Promise.all(batch.map(priceOne));
    results.push(...batchResults);
  }
  return results;
}

// Handle results
for (const { lead, quotes } of await priceBatch(leads)) {
  if (Array.isArray(quotes) && quotes.length > 0) {
    // Success — quotes[0] is the best rate
    console.log(lead.email, quotes[0].rate, quotes[0].principalAndInterest);
  } else if (quotes.status === "error") {
    // Engine rejection — log and skip
    console.log(lead.email, "error:", quotes.message);
  } else {
    // Empty array — no eligible products
    console.log(lead.email, "no products");
  }
}
```

### Python

```python
import asyncio, aiohttp, json

API_URL = "https://api.bankingbridge.com/rateflow"
API_KEY = "YOUR_API_KEY"
CONCURRENCY = 20

async def price_one(session, lead):
    payload = {
        "loid": lead["loid"],
        "list_price": lead["home_value"],
        "loan_amount": lead["loan_amount"],
        "credit_score": lead["credit_score"],
        "zipcode": lead["zipcode"],
        "loan_type": lead.get("loan_type", "conventional"),
        "loan_purpose": lead.get("loan_purpose", "purchase"),
    }
    async with session.post(API_URL, json=payload) as resp:
        body = await resp.json()
        return {"lead": lead, "quotes": body}

async def price_batch(leads):
    headers = {"x-api-key": API_KEY}
    sem = asyncio.Semaphore(CONCURRENCY)
    async with aiohttp.ClientSession(headers=headers) as session:
        async def bounded(lead):
            async with sem:
                return await price_one(session, lead)
        return await asyncio.gather(*[bounded(l) for l in leads])

results = asyncio.run(price_batch(leads))
for r in results:
    quotes = r["quotes"]
    if isinstance(quotes, list) and len(quotes) > 0:
        print(r["lead"]["email"], quotes[0]["rate"], quotes[0]["principalAndInterest"])
    elif isinstance(quotes, dict) and quotes.get("status") == "error":
        print(r["lead"]["email"], "error:", quotes["message"])
    else:
        print(r["lead"]["email"], "no products")
```

### Handling responses

There are three possible outcomes per request:

| Response Shape | Meaning | Action |
|---------------|---------|--------|
| `[{...}, {...}, ...]` (non-empty array) | Quotes returned | Use `quotes[0]` for best rate |
| `[]` (empty array) | Engine returned results but all filtered out | Log and skip — may need to adjust `target_price` |
| `{"status":"error","message":"..."}` (object) | Engine or validation error | Log the `message` and skip |

There is no `client_ref` echo — correlate inputs to outputs by position in your batch, or attach the lead object to the result as shown above.

---

## Rateflow Resolution

How the system finds which rateflow configuration (and pricing engine) to use:

```
POST /rateflow:
  if x-api-key owner is non-admin AND no id/loid in request:
    → use API key owner's default rateflow
  elif loid in request body:
    → load that LO's default rateflow
  elif nmls in request body:
    → look up LO by NMLS → load their default rateflow
  elif id in query params or body:
    → load rateflow by explicit ID

GET /rateflow:
  if loid param:    → load that LO's default rateflow
  elif email param: → look up LO by email → resolve
  elif nmls param:  → look up LO by NMLS → resolve
  elif app_key:     → resolve LO from integration → resolve
  else:             → use API key owner's default rateflow
```

---

## Defaults and Precedence

When optional fields are omitted, these defaults apply:

| Field | Default |
|-------|---------|
| `loan_type` | `"conventional"` |
| `loan_purpose` | `"purchase"` |
| `loan_term` | 30 (years) |
| `property_type` | `"single_family_home"` |
| `residency_type` | `"primary_home"` (except DSCR → `"rental_home"`) |
| `lock_period` | 60 days |
| `cash_out` | 0 |
| `target_price` | 100 (par pricing) |

### Precedence (highest wins)

1. **Request params** — values you pass in the POST body
2. **Per-LO defaults** — `lo_map[loid].defaults` in the rateflow settings
3. **Rateflow defaults** — `settings.defaults` on the rateflow
4. **System defaults** — the table above

See `rateflow-schema.md` in the BB repository for configuring `defaults` and `lo_map`.

### Per-LO defaults

The `lo_map[loid].defaults` block on a rateflow can override any of the system defaults above on a per-loan-officer basis. Common per-LO overrides: `loan_term`, `lock_period`, `loan_type`, and rate-sheet selection. When a request resolves to a specific `loid` (or the API key owner is a non-admin user), the LO's `defaults` are merged in at precedence layer 2, between request params and rateflow-wide defaults. If borrowers see different "starting" scenarios for different LOs in the same brand, this is why.

---

## Caching

Pricing results are cached by request hash. The cache TTL is configured on the rateflow (default varies by setup, typically 1-6 hours).

- **Check freshness:** Compare `lastUpdate` in the response to the current time
- **Force fresh pricing:** Pass `"ignore_cache": true` in the POST body or `ignore_cache=true` as a GET query param
- Cache is per unique combination of request parameters — changing any field produces a new cache key

> **During development**, use `"ignore_cache": true` on every request. Identical curls produce identical `lastUpdate` timestamps and identical cards by design — without `ignore_cache` you cannot tell whether you're seeing a cached response or a fresh engine call, and changes to rateflow config will not be reflected until the cache expires.

---

## Get Pricing Log Entry

Retrieve the full request and response for a previous quote. Use the `quote_id` from any rate card.

```
GET /rateflow/{rateflow_id}/log?id={quote_id}
```

```bash
curl "https://api.bankingbridge.com/rateflow/473/log?id=52235602" \
  -H "x-api-key: YOUR_API_KEY"
```

The `{rateflow_id}` in the path is required for routing only — it can be any valid rateflow ID. The `id` **query** parameter is the `quote_id` from a previous `POST /rateflow` response card. Returns the BB log row plus the raw engine request and response — useful when debugging "why did this quote come back the way it did" with support.

---

## Troubleshooting

### `quote failed - no valid quote id`

This is the most common error. It means the pricing engine found no eligible products for your scenario. Common causes:

**LTV too high for program.** The loan-to-value ratio exceeds what's allowed. Conventional typically caps at 97% LTV, jumbo at 80-90%. Check: `loan_amount / list_price * 100`. Fix: increase `list_price` or decrease `loan_amount`.

**Credit score below floor.** Most conventional programs require 620+, FHA requires 580+, jumbo typically 700+. Non-QM DSCR typically requires 660+. Fix: increase `credit_score`.

**County not served.** The pricing engine's investors may not lend in the requested area. Fix: verify the `zipcode` is correct and in a state where the rateflow's investors operate.

**Loan amount exceeds jumbo threshold but loan_type is conventional.** If `loan_amount` exceeds the county conforming limit but `loan_type` is `"conventional"`, no products match. Fix: use `"jumbo"` for high-balance loans, or reduce `loan_amount`.

**Ineligible property type for program.** FHA and VA have restrictions on condotels, co-ops, manufactured homes, etc. Fix: check that the `property_type` is eligible for the `loan_type`.

**Pricing engine is down or misconfigured.** The rateflow's engine credentials may be invalid, or the engine may be experiencing an outage. Fix: check the rateflow config via `GET /rateflow/{id}` and verify credentials.

### Other errors

| Error | Cause | Fix |
|-------|-------|-----|
| `quote failed - disabled` | Rateflow is disabled | Check `settings.disabled` on the rateflow configuration |
| `quote failed - missing rateflow id` | Could not resolve a rateflow | Provide `loid` (or `id` / `nmls`) in the request body, or ensure the API key owner has a default rateflow |
| `could not find rateflow with id` | Rateflow ID doesn't exist | Verify the rateflow ID is correct |
| `quote failed - V010 - invalid request, address must have zip or state and city` | No location provided | Include `zipcode` in the request |
| `you are not allowed to do that` (401) | Auth failed or LO not in rateflow's org | Check API key, verify LO belongs to the brand |
| Empty array `[]` | Engine returned results but all were filtered | Adjust `target_price` or check rateflow settings |
| Request timeout | Pricing engine is slow or unreachable | Retry. Non-QM quotes may take longer |

### LPC and BPC look identical

When you toggle `fees_in` and the rate pool / pricing doesn't move at all, BB is not honoring your toggle. Check, in order:

1. **`raw_fields` is shadowing the toggle.** If your request body contains `raw_fields["productCharacteristics.feesIn"]` (or any engine-native fees-in key), it overrides the top-level `fees_in`. Remove that specific key from `raw_fields` — leave other entries alone, since `raw_fields` is also the documented passthrough for engine-native overrides BB doesn't expose. See [Engine-Native Overrides via `raw_fields`](#engine-native-overrides-via-raw_fields).
2. **Both runs returned the same cached response.** Add `"ignore_cache": true` to both requests and re-test.
3. **The rateflow has a hard override.** Some rateflow configurations pin `fees_in` in `settings.defaults`. Fetch the rateflow config and inspect `settings.defaults.fees_in`. Per-LO defaults under `lo_map[loid].defaults.fees_in` apply the same way.
4. **The comparison parameters differ.** A `cash_out: 1` flag in BB vs. a realistic cash-out amount in a direct-engine run will produce different rate pools regardless of `fees_in`. Match the comparison exactly: same loan amount, list price, LTV, FICO, lock, *and same actual cash-out dollar amount*.

### My BB quote doesn't match what I see in the engine's UI

Fetch the actual outbound request BB sent to the engine — that's almost always where the mismatch lives:

```
GET /rateflow/{rateflow_id}/log?id={quote_id}
```

This returns the BB pricing log row plus the raw engine request and response. Compare the engine request against the values you typed into the engine's UI side. The most common mismatches:

- `cash_out` amount (BB request uses `1` or `0`, UI used real amount)
- `lock_period` (UI defaulted to 30, BB defaulted to 60 or vice versa)
- `target_price` (one side is at par, the other isn't)
- `residency_type` (Non-QM DSCR forces `rental_home` regardless of input)
- Auto-injected Non-QM `raw_fields` (DSCR prepayment-penalty default, etc.)

