# CFPB Consumer Complaints - US Financial (`j0401/cfpb-complaints`) Actor

The US CFPB consumer complaint database (public open data, no key, 17.7M complaints): what consumers complained about and how the company answered - product, issue, company, narrative, company response, whether it was timely, and the state. Filter or aggregate.

- **URL**: https://apify.com/j0401/cfpb-complaints.md
- **Developed by:** [Wenhao Yang](https://apify.com/j0401) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.10 / 1,000 cfpb consumer complaint records

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## CFPB Consumer Complaints - US Complaint Database

Every complaint the **US Consumer Financial Protection Bureau** has received about a financial product or service since 2011 - **17,729,722 complaints**, each keyed by the Bureau's own complaint id.

This is the register of what consumers actually complained about, to whom, and what the company said back: the product and issue classification, the company the complaint was routed to, the company's response and whether it was timely, the consumer's state, the channel it arrived through, and - for the complaints where the consumer consented - the **consumer's own narrative** in full.

### Low cost

**From $0.0001 per record.** Pay-per-event: you are charged per record delivered, and nothing for the query.

### What you get

| Mode | Returns |
|---|---|
| `rows` (default) | complaint records matching your filters, newest received first |
| `aggregate` | one count row per group for the dimension you pick |

One record is one complaint. `complaint_id` is unique - verified distinct across a 10,000-record sample - so the 17,729,722 rows are 17,729,722 complaints, not a filing or line count.

#### The narrative

`consumerNarrative` is free text, published only after the company responds **and** the consumer consents - so it lags by months and covers **3,851,416 complaints (22%)**. Where it is present it is the whole complaint in the consumer's words: measured across 200 narrative records, 50 to 19,650 characters, 1,615 on average.

Because narratives lag, the newest complaints never carry one: **0 of the 200 most recently received complaints have a narrative.** Ask for `hasNarrative=true` when you want depth, and leave it off when you want volume - the difference is roughly 680 bytes per record against several KB. Narrowing to the last 30 days and asking for narratives together returns nothing at all, so when you ask for narratives without naming a window the actor widens to all time and says so in the run log.

### Coverage in the register

- **Product** - 21 values. `Credit reporting or other personal consumer reports` **12,173,409**; `Credit reporting, credit repair services, or other personal consumer reports` **2,163,770**; `Debt collection` **1,190,725**; `Mortgage` **459,987**; `Checking or savings account` **394,828**; `Credit card` **342,402**; and 15 more historical and current categories.
- **Sub-product** - 86 distinct values once the per-product buckets are flattened together (the source nests them, and the same sub-product name recurs under several products), headed by `Credit reporting` **14,269,943**. 235,275 complaints (1.3%) carry no sub-product at all.
- **Issue** - 178 values. `Incorrect information on your report` **7,998,907**; `Improper use of your report` **3,390,266**; `Problem with a company's investigation into an existing problem` **2,304,573**.
- **State** - 63 values including territories and military codes. `TX` **2,441,767**; `FL` **2,428,692**; `CA` **1,758,940**; `GA` **1,307,037**; `NY` **1,026,335**.
- **Company response** - 8 values. `Closed with explanation` **10,867,098**; `Closed with non-monetary relief` **5,946,287**; `In progress` **623,834**; `Closed with monetary relief` **218,246**. Note `Closed` (17,611) is a value of its own, not a truncation of the longer ones.
- **Timely** - `Yes` **17,621,023**, `No` **108,699**.
- **Submitted via** - `Web` **17,093,819**; `Referral` **278,604**; `Phone` **220,732**; `Postal mail` **110,500**; `Fax` **25,644**; `Email` **423**.
- **Tags** - the Bureau's special-population markers: `Servicemember` **511,999**; `Older American` **234,545**; both **58,798**.

### The three big credit bureaus

`company` matches the Bureau's own normalized company name **exactly**:

| Company | Complaints |
|---|---|
| `TRANSUNION INTERMEDIATE HOLDINGS, INC.` | 4,887,642 |
| `EQUIFAX, INC.` | 4,693,894 |
| `Experian Information Solutions Inc.` | 4,273,228 |

A partial or lowercase name matches nothing - `equifax` returns zero, because the field is not tokenized the way the free-text index is. Use `searchTerm` for anything partial, or take the exact name from an `aggregate` run and feed it back.

### Examples

**Everything about one company last month** - `company=EQUIFAX, INC.`, `dateFrom=2026-08-01`, `dateTo=2026-08-31`.

**Free text across the whole register** - `searchTerm=foreclosure` matches **31,902** complaints wherever the term occurs in the record, not just the narrative.

**Where complaints come from** - `mode=aggregate`, `groupBy=state`, `recentDays=0` returns all 63 values, largest first. (The default 30-day window applies to aggregate mode too - leave `recentDays` alone and you get the 60 states with a complaint in the last month. Set it to 0 for the whole register.)

**What companies do about them** - `mode=aggregate`, `groupBy=companyResponse`, `product=Debt collection`.

**Just the stories** - `hasNarrative=true`, `maxResults=500`, filtered to one issue.

### Modes and filters

`mode=rows` accepts `searchTerm`, `company`, `product`, `issue`, `state`, `tags`, `companyResponse`, `timely`, `submittedVia`, `hasNarrative`, `dateFrom` / `dateTo`, and `recentDays`.

`mode=aggregate` counts by `product`, `subProduct`, `issue`, `state`, `companyResponse`, `submittedVia`, `timely`, `tags` or `hasNarrative` - and honours every filter above, so "issue counts for Texas servicemembers last quarter" is one run.

`recentDays` applies only to a bare rows run (30 days, ~558k complaints); `dateFrom` overrides it and `recentDays=0` spans the whole register since 2011.

### Source notes

The Bureau's complaint search runs on Elasticsearch, and the edges of that are where the work is:

- **The register is behind an edge that refuses browser User-Agent strings.** A browser UA - and an empty one - get a raw HTML 403 from the CDN, not from the API. A plain bot-style UA with a contact token is served normally. Worked out and pinned, so the actor does not fall over on an "obvious" header.
- **Offset paging does not work, and fails silently.** The endpoint validates a `frm` offset - reject it and you get `frm is not zero or a multiple of size` - but accepts any valid one and returns **the same first page**. Probing offsets 0, 3, 30, 300 and 3,000 for one page size returns ten identical records, and page 1 against page 2 overlaps 1,000 of 1,000. A naive loop "pages" forever over the same hundred complaints and bills for every duplicate. This actor fetches one page and is bounded by the source's 10,000-record window instead.
- **The source's own `company` aggregation is truncated** - it hands back 6,500 buckets and quietly accounts for another 1,830 companies under "other". `company` is therefore offered as an exact filter but deliberately withheld as a group-by dimension: a "top companies" chart from it would be missing a tail of thousands.
- **Some fields look filterable and are not.** `sub_product`, `sub_issue` and `consumer_disputed` are accepted and ignored - each returns the full 17.7-million-row register. (`field` is rejected outright by the source for most values, so it is unusable too.) Accepting them would hand back unfiltered records at per-record prices, so they are not offered; supplying one is logged as ignored rather than silently dropped. `subProduct` survives as an output field and as a group-by dimension, flattened out of the nested source aggregation.
- **`timely=No` is 108,699 complaints, not zero.** The `timely` field is only meaningful once a company has responded, so a naive read of it undercounts.

Freshness is guarded on every run: the Bureau stamps the index (`last_updated`, rebuilt daily) and flags its own staleness, and a broken refresh fails the run rather than serving a frozen register.

### Source

> **Counts are a live snapshot** - the Bureau appends complaints continuously, so exact figures drift daily. The proportions are stable.

US Consumer Financial Protection Bureau, [Consumer Complaint Database](https://www.consumerfinance.gov/data-research/consumer-complaints/) - public open data, released under **CC0**, no login and no API key. The register starts in **2011** (2,536 complaints that first year against 17.7M today) and is rebuilt daily.

### Output

Every record carries the same key set in every mode - the complaint fields, plus `mode`, `groupBy`, `groupKey` and `groupCount`, which are `""` outside aggregate mode. Missing values are `''` rather than absent; `tags` is flattened from the source's array to a comma-joined string, and `hasNarrative` is a `"true"` / `"false"` string.

### Related actors

- **SAM.gov Contracts** (`sam-gov-contracts`) - federal contract opportunities and awards.
- **FMCSA Carrier Safety** (`fmcsa-carrier-safety`) - the federal motor-carrier register.
- **WA AG Complaints** (`wa-ag-complaints`) - state-level consumer complaints for Washington.

# Actor input Schema

## `mode` (type: `string`):

rows = complaint records matching your filters, newest received first (default). aggregate = one count row per group for the chosen groupBy dimension.

## `searchTerm` (type: `string`):

Free-text search across the complaint record, e.g. 'foreclosure' (~32k complaints), 'overdraft', 'identity theft'. Blank = any.

## `company` (type: `string`):

EXACT company name as the Bureau normalizes it, e.g. 'EQUIFAX, INC.' (~4.7M), 'TRANSUNION INTERMEDIATE HOLDINGS, INC.', 'Experian Information Solutions Inc.'. A partial or lowercase value matches nothing — use searchTerm instead. Blank = any.

## `product` (type: `string`):

Exact product (all 21 values the Bureau publishes). Credit reporting dominates at ~12.2M, Debt collection ~1.19M, Mortgage ~460k. Blank = any.

## `issue` (type: `string`):

EXACT issue name (178 values), e.g. 'Incorrect information on your report' (~8.0M), 'Improper use of your report', 'Attempts to collect debt not owed'. A partial value matches nothing. Blank = any.

## `state` (type: `string`):

Exact two-letter state of the consumer (all 63 values the source returns, including territories and military codes). TX ~2.44M, FL ~2.43M, CA ~1.76M. Blank = any.

## `tags` (type: `string`):

The Bureau's own special-population tag. Servicemember ~512k, Older American ~235k, both ~59k. Blank = any.

## `companyResponse` (type: `string`):

Exact company response (all 8 values). 'Closed with explanation' ~10.9M, 'Closed with non-monetary relief' ~5.9M, 'In progress' ~624k. Blank = any.

## `timely` (type: `string`):

Whether the company responded within the period set by statute. Yes ~17.6M, No ~109k. Blank = any.

## `submittedVia` (type: `string`):

The channel the complaint came through. Web ~17.1M, Referral ~279k, Phone ~221k, Postal mail ~111k. Blank = any.

## `hasNarrative` (type: `string`):

Leave blank for all complaints. 'true' = only complaints whose consumer narrative was published (~3.85M). 'false' = only those without one. Blank is NOT the same as 'false' — 'false' excludes every narrative record.

## `dateFrom` (type: `string`):

Only complaints received on or after this date (YYYY-MM-DD). Takes precedence over recentDays. Blank = any.

## `dateTo` (type: `string`):

Only complaints received on or before this date (YYYY-MM-DD). Blank = any.

## `recentDays` (type: `integer`):

'Received in the last N days' (e.g. 30). 0 = no window of its own — which is the default, and how you get a company's or a product's FULL history. A run with no other filter and 0 here still works (it pages the whole register). Ignored when dateFrom is set.

## `groupBy` (type: `string`):

Dimension to count by in mode=aggregate: product (21 values), subProduct (86), issue (178), state (63), companyResponse (8), submittedVia (6), timely (2), tags (3), hasNarrative (2). Not used in rows mode.

## `maxResults` (type: `integer`):

Cap on records pushed in rows mode (0 = up to 10,000, the source's own result window). Each record is metered individually, so there is no per-run charge cap. Aggregate mode returns every group.

## Actor input object example

```json
{
  "mode": "rows",
  "searchTerm": "",
  "company": "",
  "product": "",
  "issue": "",
  "state": "",
  "tags": "",
  "companyResponse": "",
  "timely": "",
  "submittedVia": "",
  "hasNarrative": "",
  "dateFrom": "",
  "dateTo": "",
  "recentDays": 0,
  "groupBy": "product",
  "maxResults": 50
}
```

# Actor output Schema

## `recordsUrl` (type: `string`):

CFPB complaint records or aggregate counts - as JSON

## `datasetUrl` (type: `string`):

No description

## `runUrl` (type: `string`):

No description

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("j0401/cfpb-complaints").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("j0401/cfpb-complaints").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call j0401/cfpb-complaints --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,j0401/cfpb-complaints"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/UQeDynGSkC9CdMewP/builds/gkqJc73cvw9qVDhEl/openapi.json
