# OfferMarket DSCR Quote API

Instant rate estimates for business-purpose real estate investor loans. No name, SSN, or credit pull is needed. The first 10 calls need no login; then log in with a free OfferMarket login. The steps to sign up and stay logged in are in components.securitySchemes.firebase and at https://cms.offermarket.us/llms.txt. Try it in a browser, and copy your own login token, at https://www.offermarket.us/developers#try-it. Estimates are not loan offers. Every call is kept, including the property address and the login it came from.

- Endpoint: `POST https://cms.offermarket.us/public/v1/quotes/dscr`
- Open a loan file from a quote: `POST https://cms.offermarket.us/public/v1/quotes/{request_id}/loan-file` (the quote's `next_step_url`)
- OpenAPI spec: https://cms.offermarket.us/public/v1/openapi.json
- Markdown copy of this page: https://www.offermarket.us/developers/dscr-quote-api.md
- Try it in a browser, and copy your own login token: https://www.offermarket.us/developers#try-it

## Try it

Returns loan options at each loan-to-value level our capital providers offer for this property, rent, and borrower. Fields left out are filled with documented defaults and listed in assumptions.

```bash
curl -s https://cms.offermarket.us/public/v1/quotes/dscr \
  -H 'Content-Type: application/json' \
  -d '{"loan_purpose":"purchase","address":"123 Main St, Indianapolis, IN 46204","property_type":"single_family","purchase_price":300000,"monthly_rent":2600,"credit_score":740}'
```

Check `status` first. `quoted` comes with `options`, one per loan-to-value level. `no_offer` comes with `reasons`, and each reason names the field to look at.

## Every field

This request sends every field. Every field (a refinance). The portfolio fields (property_count, state, counties) are left out: they replace address and property_type. Every field is described under **Request** below. Fields you leave out get defaults, listed in `assumptions`.

```bash
curl -s https://cms.offermarket.us/public/v1/quotes/dscr \
  -H 'Content-Type: application/json' \
  -d @- <<'JSON'
{
  "loan_purpose": "refinance",
  "address": "123 Main St, Indianapolis, IN 46204",
  "property_type": "duplex",
  "units": 2,
  "unit_size_sqft": 950,
  "purchase_price": 280000,
  "as_is_value": 400000,
  "monthly_rent": 3400,
  "leasing_strategy": "long_term",
  "tenant_type": "market",
  "annual_taxes": 4200,
  "annual_insurance": 2100,
  "annual_hoa": 0,
  "credit_score": 740,
  "experience_deals": 5,
  "entity": "llc",
  "citizenship": "us_citizen",
  "refinance_type": "cash_out",
  "existing_debt": 150000,
  "purchase_date": "2021-06-15",
  "capital_improvements": 25000,
  "loan_amount": 260000,
  "amortization": "fully_amortizing"
}
JSON
```

The response. Rates here are illustrative, not live.

```json
{
  "program": "dscr",
  "status": "quoted",
  "request_id": "3f9a1c2e7b4d8a60",
  "rates_as_of": "2026-10-09T13:00:00.000Z",
  "options": [
    {
      "ltv": 60,
      "loan_amount": 240000,
      "interest_rate": 7,
      "monthly_principal_and_interest": 1596.73,
      "monthly_payment_total": 2121.73,
      "dscr": 1.6,
      "prepayment_penalty": "5-4-3-2-1",
      "covers_requested_amount": false
    },
    {
      "ltv": 65,
      "loan_amount": 260000,
      "interest_rate": 7,
      "monthly_principal_and_interest": 1729.79,
      "monthly_payment_total": 2254.79,
      "dscr": 1.51,
      "prepayment_penalty": "5-4-3-2-1",
      "covers_requested_amount": true
    },
    {
      "ltv": 70,
      "loan_amount": 280000,
      "interest_rate": 7.25,
      "monthly_principal_and_interest": 1910.09,
      "monthly_payment_total": 2435.09,
      "dscr": 1.4,
      "prepayment_penalty": "5-4-3-2-1",
      "covers_requested_amount": true
    },
    {
      "ltv": 75,
      "loan_amount": 300000,
      "interest_rate": 7.5,
      "monthly_principal_and_interest": 2097.64,
      "monthly_payment_total": 2622.64,
      "dscr": 1.3,
      "prepayment_penalty": "5-4-3-2-1",
      "covers_requested_amount": true
    }
  ],
  "reasons": [],
  "assumptions": [],
  "disclaimer": "Estimate only. This is not a loan offer, a pre-approval, a commitment to lend, or a guarantee of any rate or term. Rates change daily. Final terms depend on underwriting, the appraisal, and approval by OfferMarket Capital LLC and its capital providers. For business-purpose loans on non-owner-occupied investment property only. Terms: https://offermarket.us/terms-of-service (Section 2.2.5). Licenses: https://offermarket.us/licenses.",
  "next_step_url": "https://cms.offermarket.us/public/v1/quotes/3f9a1c2e7b4d8a60/loan-file"
}
```

## Open a loan file from a quote

Needs a login: send `Authorization: Bearer <id_token>`. With no login, it returns 401 LOGIN_REQUIRED with the login steps in error.log_in. Opening a loan file from the API is still being built. This call tells OfferMarket you want one for this quote, and someone emails the login's address to set it up. The quote can be one you made before you logged in. GET and POST do the same thing.

```bash
curl -s -X POST 'https://cms.offermarket.us/public/v1/quotes/<request_id>/loan-file' \
  -H 'Authorization: Bearer <id_token>'
```

With no login, the call returns `401 LOGIN_REQUIRED` with the login steps in `error.log_in`.

```json
{
  "request_id": "3f9a1c2e7b4d8a60",
  "status": "received",
  "email": "you@example.com",
  "message": "Thanks. Opening a loan file from the API is still being built. Someone from OfferMarket will email you at you@example.com to set it up."
}
```

## Logging in

An OfferMarket login, sent as `Authorization: Bearer <id_token>`. The `id_token` is a Firebase ID token. It works for 1 hour, then you sign in again for a new one.

With no login, each caller (one IP address) gets 10 calls in total. Every quote or 400 counts. The next call gets `429 FREE_CALLS_USED`. `GET https://cms.offermarket.us/public/v1/free-calls` tells you how many are left. To keep quoting, log in. A login is free and gets 30 calls a minute.

To test in a browser first, go to https://www.offermarket.us/developers#try-it. You can send requests there, see your free calls, log in, and copy your own `id_token` and `refresh_token`.

A login is an email and a password. An agent signs up for its user: send the user's email and a password you make up and keep. One call to us makes the login and accepts the terms. In exchange for free quotes, we may send marketing email to that address; the user can unsubscribe at any time. When the token runs out after 1 hour, send the same call again. Every step is one HTTP call, so no browser is needed. Do the steps in this order:

```text
You send                              --->  You get back
Quote, no login (calls 1 to 10)       --->  200 quote
Quote, no login (call 11)             --->  429 FREE_CALLS_USED
1. Sign up + accept the terms (once)  --->  id_token + refresh_token
2. Quote + Bearer id_token            --->  200 quote (30 a minute)
   ... 1 hour later, or 401 INVALID_TOKEN ...
3. Send step 1 again (it signs in)    --->  new id_token
   then go back to step 2
```

### 1. Sign up and accept the terms (one call, once)

Do this once. It makes your login, accepts the OfferMarket terms, and gives you tokens. Use your user's real email and a password you make up and keep, 6 or more characters. If the email already has a login, the same call signs in with the password instead. Each IP address, and each email, gets 10 tries an hour.

```bash
curl -s 'https://cms.offermarket.us/public/v1/account' \
  -H 'Content-Type: application/json' \
  -d '{"email":"<email>","password":"<password>","accept_terms":true}'
```

| Reply field | What it is |
| --- | --- |
| `id_token` | The login token. Send it on every quote as `Authorization: Bearer <id_token>`. It works for 1 hour. |
| `refresh_token` | Save it with your secrets, in place of any older one. The refresh step trades it for a new `id_token`. |
| `expires_in` | Seconds until the `id_token` stops working: 3600 (1 hour). |
| `login_created` | true when this call made the login. false when it signed in to a login you already had. |

### 2. Quote with the login

Send the `id_token` on every quote. The body is the same as an open call. A login gets 30 calls a minute.

```bash
curl -s 'https://cms.offermarket.us/public/v1/quotes/dscr' \
  -H 'Authorization: Bearer <id_token>' \
  -H 'Content-Type: application/json' \
  -d '<the same JSON as an open call>'
```

### 3. Get a new token (every hour)

Do this when a call returns `401 INVALID_TOKEN`, or the `id_token` is close to 1 hour old. It is the sign_up call again, with the same email and password. It signs in and gives a new `id_token`. Then send the failed call again, once.

```bash
curl -s 'https://cms.offermarket.us/public/v1/account' \
  -H 'Content-Type: application/json' \
  -d '{"email":"<email>","password":"<password>","accept_terms":true}'
```

| Reply field | What it is |
| --- | --- |
| `id_token` | The login token. Send it on every quote as `Authorization: Bearer <id_token>`. It works for 1 hour. |
| `refresh_token` | Save it with your secrets, in place of any older one. The refresh step trades it for a new `id_token`. |
| `expires_in` | Seconds until the `id_token` stops working: 3600 (1 hour). |

### 4. Optional: refresh the token without the password

You can skip this step. It does the same job as sign_in, with the `refresh_token` in place of the password. This call goes to Google's login service (Firebase), not to us. Its body is a form, not JSON.

```bash
curl -s 'https://securetoken.googleapis.com/v1/token?key=AIzaSyDyib9HegXAt3apVxbKlJ_-kYQJ1_dHsQA' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=refresh_token&refresh_token=<refresh_token>'
```

| Reply field | What it is |
| --- | --- |
| `id_token` | The login token. Send it on every quote as `Authorization: Bearer <id_token>`. It works for 1 hour. |
| `refresh_token` | Save it with your secrets, in place of any older one. The refresh step trades it for a new `id_token`. |
| `expires_in` | Seconds until the `id_token` stops working: 3600 (1 hour). |

### Rules for staying logged in

- An `id_token` works for 1 hour. Keep using it until then.
- Save the email and the password with your secrets. Give them to your user if they ask. Do not put them, or an `id_token`, in a web page, a URL, or a log.
- When a call returns `401 INVALID_TOKEN`, sign in again (the sign_up call), then send that call again once with the new `id_token`.
- Sign in once an hour, not for every quote. Each IP address, and each email, gets 10 email and password tries an hour. The optional refresh step has no such limit.
- A wrong password gets `401 INVALID_LOGIN`. Do not make a second login: send the right password, or reset it.
- One login is for one person or company. Sending `accept_terms` accepts the terms for them.
- This login is for the API only. It does not log in at https://offermarket.us, and its token works on no other OfferMarket route.
- We pass the password to the login service and never store or log it.
- Forgot the password? Send `POST https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=AIzaSyDyib9HegXAt3apVxbKlJ_-kYQJ1_dHsQA` with the JSON `{"requestType":"PASSWORD_RESET","email":"<email>"}`. We email a link to set a new password.
- Logged in at https://offermarket.us? That login works here too. Get its `id_token` at https://www.offermarket.us/developers#try-it. That page also shows how to refresh it: a site login refreshes with a different `key`. If you have not accepted the terms, send `POST https://cms.offermarket.us/public/v1/account` with `Authorization: Bearer <id_token>` and only `{"accept_terms": true}`.
- The `key` in the Google URLs is OfferMarket's public web key for logins. It is the same for every caller and it is not a secret. Your secrets are the password and the `refresh_token`.

## Limits

When you reach a limit, the API answers `429`. `RATE_LIMITED` comes with a `Retry-After` header in seconds. `FREE_CALLS_USED` means the free calls are used up: log in to keep quoting. When a call needs a login, the error has `error.log_in`: the same login steps as above, as JSON. Every answer has an `X-Request-Id` header; quote it when you contact us.

## More example responses

Rates in these examples are illustrative, not live.

### Quoted

```json
{
  "program": "dscr",
  "status": "quoted",
  "request_id": "3f9a1c2e7b4d8a60",
  "rates_as_of": "2026-10-09T13:00:00.000Z",
  "options": [
    {
      "ltv": 75,
      "loan_amount": 225000,
      "interest_rate": 7.25,
      "monthly_principal_and_interest": 1534.9,
      "monthly_payment_total": 2022.4,
      "dscr": 1.29,
      "prepayment_penalty": "5-4-3-2-1",
      "covers_requested_amount": null
    },
    {
      "ltv": 80,
      "loan_amount": 240000,
      "interest_rate": 7.5,
      "monthly_principal_and_interest": 1678.11,
      "monthly_payment_total": 2165.61,
      "dscr": 1.2,
      "prepayment_penalty": "5-4-3-2-1",
      "covers_requested_amount": null
    }
  ],
  "reasons": [],
  "assumptions": [
    {
      "field": "annual_taxes",
      "value": 3600,
      "note": "Not sent; estimated as 1.20% of purchase_price. Coastal states often cost more."
    },
    {
      "field": "annual_insurance",
      "value": 2250,
      "note": "Not sent; estimated as 0.75% of purchase_price. Coastal states often cost more."
    },
    {
      "field": "annual_hoa",
      "value": 0,
      "note": "Not sent; assumed no HOA dues."
    },
    {
      "field": "experience_deals",
      "value": 2,
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "entity",
      "value": "llc",
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "citizenship",
      "value": "us_citizen",
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "leasing_strategy",
      "value": "long_term",
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "tenant_type",
      "value": "market",
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "amortization",
      "value": "fully_amortizing",
      "note": "Not sent; a typical value was used."
    }
  ],
  "disclaimer": "Estimate only. This is not a loan offer, a pre-approval, a commitment to lend, or a guarantee of any rate or term. Rates change daily. Final terms depend on underwriting, the appraisal, and approval by OfferMarket Capital LLC and its capital providers. For business-purpose loans on non-owner-occupied investment property only. Terms: https://offermarket.us/terms-of-service (Section 2.2.5). Licenses: https://offermarket.us/licenses.",
  "next_step_url": "https://cms.offermarket.us/public/v1/quotes/3f9a1c2e7b4d8a60/loan-file"
}
```

### No offer: our capital providers do not fund loans in the property's state

```json
{
  "program": "dscr",
  "status": "no_offer",
  "request_id": "3f9a1c2e7b4d8a60",
  "rates_as_of": "2026-10-09T13:00:00.000Z",
  "options": [],
  "reasons": [
    {
      "code": "STATE_NOT_SERVED",
      "field": "address",
      "hint": "Our capital providers do not fund this loan in IN.",
      "value": "IN"
    }
  ],
  "assumptions": [
    {
      "field": "annual_taxes",
      "value": 3600,
      "note": "Not sent; estimated as 1.20% of purchase_price. Coastal states often cost more."
    },
    {
      "field": "annual_insurance",
      "value": 2250,
      "note": "Not sent; estimated as 0.75% of purchase_price. Coastal states often cost more."
    },
    {
      "field": "annual_hoa",
      "value": 0,
      "note": "Not sent; assumed no HOA dues."
    },
    {
      "field": "experience_deals",
      "value": 2,
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "entity",
      "value": "llc",
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "citizenship",
      "value": "us_citizen",
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "leasing_strategy",
      "value": "long_term",
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "tenant_type",
      "value": "market",
      "note": "Not sent; a typical value was used."
    },
    {
      "field": "amortization",
      "value": "fully_amortizing",
      "note": "Not sent; a typical value was used."
    }
  ],
  "disclaimer": "Estimate only. This is not a loan offer, a pre-approval, a commitment to lend, or a guarantee of any rate or term. Rates change daily. Final terms depend on underwriting, the appraisal, and approval by OfferMarket Capital LLC and its capital providers. For business-purpose loans on non-owner-occupied investment property only. Terms: https://offermarket.us/terms-of-service (Section 2.2.5). Licenses: https://offermarket.us/licenses.",
  "next_step_url": "https://cms.offermarket.us/public/v1/quotes/3f9a1c2e7b4d8a60/loan-file"
}
```

## Request

Send JSON. Unknown fields are rejected, so a typo is reported instead of ignored.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `loan_purpose` | one of: `purchase`, `refinance` | Yes | Purchase or refinance. |
| `address` | string |  | Full US street address: number and street, city, two-letter state code, and ZIP. Required for one property. Not used for a portfolio. |
| `property_type` | one of: `single_family`, `duplex`, `triplex`, `quadplex`, `condominium`, `5_plus_units`, `mixed_use` |  | Required for one property. Not used for a portfolio. |
| `units` | integer |  | Number of units. Required for 5_plus_units, mixed_use, and a portfolio (the total for all properties). Otherwise read from property_type. |
| `unit_size_sqft` | number |  | Size of the smallest unit in square feet. |
| `purchase_price` | number | Yes | What the property costs (a purchase) or cost when it was bought (a refinance). For a portfolio, the total. |
| `as_is_value` | number |  | Current value. Required for a refinance. Optional for a purchase. For a portfolio, the total. |
| `monthly_rent` | number | Yes | Gross monthly rent, actual or market. For a portfolio, the total. |
| `leasing_strategy` | one of: `long_term`, `mid_term`, `short_term` |  | Default long_term. |
| `tenant_type` | one of: `market`, `section_8` |  | Default market. |
| `annual_taxes` | number |  | Default 1.2% of as_is_value (or purchase_price when no as_is_value). |
| `annual_insurance` | number |  | Default 0.75% of as_is_value (or purchase_price when no as_is_value). |
| `annual_hoa` | number |  | Default 0. |
| `credit_score` | integer | Yes | Self-reported credit score of the lead borrower. |
| `experience_deals` | integer |  | Investment properties owned in the last 10 years (rented, flipped, or rehabbed). Default 2. |
| `entity` | one of: `llc`, `corporation`, `trust`, `personal_name` |  | Who takes title. Default llc. |
| `citizenship` | one of: `us_citizen`, `green_card_holder`, `foreign_national` |  | Default us_citizen. |
| `refinance_type` | one of: `cash_out`, `rate_term` |  | Cash out or rate and term. Required for a refinance, and only then. |
| `existing_debt` | number |  | Payoff of current loans. 0 when the property is owned free and clear. Required for a refinance, and only then. |
| `purchase_date` | string |  | YYYY-MM-DD, when the property was bought. A purchase within 180 days limits the loan to what it cost. Required for a refinance, and only then. |
| `capital_improvements` | number |  | Money spent on the property since purchase. 0 when none. Required for a refinance, and only then. |
| `property_count` | integer |  | Number of properties. Default 1. 2 or more makes this a portfolio loan: all in one state. |
| `state` | string |  | State code or name of a portfolio, for example "OH". Required for a portfolio, and only then. |
| `counties` | array of string |  | Counties of a portfolio. Optional, and only for a portfolio. |
| `loan_amount` | number |  | Optional. Options large enough for it get covers_requested_amount = true. |
| `amortization` | one of: `fully_amortizing`, `interest_only` |  | Default fully_amortizing. |

## Response

| Field | Type | Description |
| --- | --- | --- |
| `program` | string |  |
| `status` | one of: `quoted`, `no_offer` | no_offer means no capital provider can quote this loan as entered; see reasons. |
| `request_id` | string |  |
| `rates_as_of` | string | When the rate sheets behind this quote were loaded. Rates change daily. |
| `options[]` | array of objects | One option per loan-to-value level, lowest LTV first. |
| `options[].ltv` | number | Loan-to-value, percent of property value. |
| `options[].loan_amount` | number | Loan amount in dollars. |
| `options[].interest_rate` | number | Interest rate, percent per year. |
| `options[].monthly_principal_and_interest` | number | Monthly principal and interest. |
| `options[].monthly_payment_total` | number | Principal and interest plus taxes, insurance, and HOA. This is the payment DSCR is based on. |
| `options[].dscr` | number | Monthly rent divided by monthly_payment_total. |
| `options[].prepayment_penalty` | string | Penalty by year, in percent. "5-4-3-2-1" is 5% in year 1, 4% in year 2, and so on. |
| `options[].covers_requested_amount` | boolean | Whether this option's loan_amount is at least the loan_amount you sent. Null when you sent none. |
| `reasons[]` | array of objects | Why no option was offered. Empty when status is quoted. |
| `reasons[].code` | string | One of PORTFOLIO_NOT_SUPPORTED, UNIT_SIZE_BELOW_MINIMUM, STATE_NOT_SERVED, CITY_NOT_SERVED, DSCR_TOO_LOW, LOAN_AMOUNT_BELOW_MINIMUM, or a code that names the field: <FIELD>_BELOW_MINIMUM (for example CREDIT_SCORE_BELOW_MINIMUM) or <FIELD>_NOT_ELIGIBLE (for example PROPERTY_TYPE_NOT_ELIGIBLE). |
| `reasons[].field` | string | The request field the reason is about. |
| `reasons[].hint` | string |  |
| `reasons[].minimum` | number | The lowest value any capital provider accepts. Sent with *_BELOW_MINIMUM and DSCR_TOO_LOW. |
| `reasons[].allowed` | array of string | Every value any capital provider accepts for field. Sent with *_NOT_ELIGIBLE. |
| `reasons[].value` | string | The value that is not served, for example the state code. Sent with STATE_NOT_SERVED and CITY_NOT_SERVED. |
| `assumptions[]` | array of objects | Every value that was not sent and was filled in, and notes that affect the quote. |
| `assumptions[].field` | string |  |
| `assumptions[].value` | any |  |
| `assumptions[].note` | string |  |
| `disclaimer` | string |  |
| `next_step_url` | string | Call it (GET or POST) to ask OfferMarket to turn this quote into a loan file. See the loan-file path. |

### Reason codes



## Errors

| HTTP status | Meaning |
| --- | --- |
| 200 | Quote result. Check status: quoted or no_offer. |
| 400 | The request is not valid. fields lists each problem. |
| 401 | INVALID_TOKEN: the id_token is invalid, expired (they work for 1 hour), or revoked. Run the sign_in step in error.log_in (the sign_up call again), then send the call again once. |
| 403 | PROFILE_REQUIRED: the login has not accepted the terms. Call POST /public/v1/account first. |
| 429 | RATE_LIMITED: wait for Retry-After seconds. FREE_CALLS_USED: an open caller has used all 10 free calls; log in to keep quoting (no Retry-After). Both list the login steps in error.log_in when the call had no login. |
| 503 | Too many quotes are running. Retry after Retry-After seconds. |

Error bodies have `error.code` (`INVALID_REQUEST`, `INVALID_TOKEN`, `INVALID_LOGIN`, `LOGIN_REQUIRED`, `PROFILE_REQUIRED`, `FREE_CALLS_USED`, `RATE_LIMITED`, `BUSY`, `INTERNAL`), `error.message`, and for `INVALID_REQUEST` a `fields` list and a valid `example`.

## Rules

- Estimates are not loan offers. Rates change daily; see `rates_as_of`.
- No name, SSN, or credit pull is needed. Every call is kept, including the property address and the login or IP address it came from.
- Fields you leave out are filled with defaults and listed in `assumptions`. Send real taxes and insurance for a better estimate.
- To turn a quote into a loan file, log in and call its `next_step_url` with the login token. Someone from OfferMarket emails the login's address to set it up.
