SBA Loan Portfolio Explorer API — 7(a) & 504 Rollups
Pricing
from $4.40 / 1,000 results
SBA Loan Portfolio Explorer API — 7(a) & 504 Rollups
SBA loan data API over the 7(a) and 504 FOIA loan-level files. Look up loans by state, NAICS industry, lender, fiscal year and amount; roll up by lender / industry / geography; and flag risk outliers (amount-per-job, charge-offs, lender concentration). Keyless.
Pricing
from $4.40 / 1,000 results
Rating
0.0
(0)
Developer
Kyle Maloney
Maintained by CommunityActor stats
0
Bookmarked
4
Total users
1
Monthly active users
21 days ago
Last modified
Categories
Share
SBA Loan Portfolio Explorer API - 7(a) & 504 Rollups
A keyless SBA loan data API over the official 7(a) and 504 FOIA loan-level
files on data.sba.gov. Look up SBA loans by state, NAICS industry, lender,
fiscal year, approval-date window and amount - or roll the same population up
into one row per lender, NAICS industry or state with loan counts,
approved and guaranteed dollars, a charge-off rate and jobs supported. SBA
publishes this only as six static CSVs totalling about 967 MB, with no query API
in front of them. This Actor streams them, normalises 7(a) and 504 into one
snake_case schema, filters, and adds the aggregation and outlier layer the raw
files lack.
Built for lenders and credit teams benchmarking their book, CDFIs sizing a market, fintech underwriters pulling industry-level default proxies, franchise and business brokers finding which lenders fund their sector, economic-development analysts measuring SBA capital flow, and AI agents needing an SBA lookup and rollup tool.
Scope and cost - read this first
Nothing is required. Every input is optional, so a bare {} call is legal
and runs nationally across both programs. Always set the scope you want, because
scope drives the bill.
{ "program": "7a", "state": "TX", "mode": "rollup-by-lender", "maxResults": 100 }
Cost. Pay-per-result at $8.00 per 1,000 rows ($0.008 per row, about $0.80 per 100), less a graduated 20-70% discount on paid Apify plans. Only rows written to the dataset are billed. A run rejected before fetching bills no results; it still incurs the standard per-run actor-start event, a small fraction of a cent.
The two caps are different, and this is the most misread thing here:
| Input | What it bounds | Affects your bill? |
|---|---|---|
maxResults | Matching loans fetched, so rows emitted and billed | Yes - the billing cap |
maxScanRows | CSV rows read per file, matching or not | No - reading and runtime only |
maxScanRows cannot make a run cost more. A narrow filter may read hundreds of
thousands of rows before finding maxResults matches, and this is the stop
switch on that reading. The emit cap is enforced in one place every output mode
is structurally forced through, and re-checked immediately before anything is
written, so no mode can bypass it.
Worst case on the defaults. maxResults defaults to 1000, so an
unscoped {} call bills at most $8.00. The Console prefill is 50
($0.40). The maximum accepted is 200,000 - $1,600 at list price, scaling
linearly.
Measured runs, on build 1.0.15, which resolved the as-of 2026-03-31 extract. Rows billed equalled rows emitted in every case. The per-row economics are unchanged by the extract refresh described below; the specific loans returned are not, because a newer extract carries newer loans.
| Input | Rows emitted | Runtime | Cost |
|---|---|---|---|
{program:"7a", state:"CA", mode:"loans", maxResults:200} | 200 loans | 10 s | $1.60 |
{program:"7a", state:"TX", mode:"rollup-by-lender", maxResults:100} | 52 lender rows | 7 s | $0.42 |
{program:"all", mode:"loans", maxResults:1000} | 1000 loans | 29 s | $8.00 |
Console prefill (504, TX, 50) | 50 loans | 4 s | $0.40 |
Note the rollup: 100 loans were fetched under the cap and collapsed into 52 groups, and 52 rows were billed, not 100. In rollup modes you pay for groups, so a rollup usually costs less than the loan-level run behind it.
What data it covers
SBA's 7(a) & 504 FOIA dataset (SBA-OHA-2016-08-001) - the loan-level
extract published under the Freedom of Information Act, refreshed quarterly.
There is no query API; SBA publishes six static CSVs and this Actor streams
and parses them. Sizes re-measured live 2026-08-25 on the as-of 2026-06-30
extract:
| Program | Coverage | Size | Program | Coverage | Size | |
|---|---|---|---|---|---|---|
| 7(a) | FY2020 - present | 172.7 MB | 7(a) | FY2000 - FY2009 | 303.7 MB | |
| 7(a) | FY2010 - FY2019 | 243.3 MB | 7(a) | FY1991 - FY1999 | 139.6 MB | |
| 504 | FY2010 - present | 56.7 MB | 504 | FY1991 - FY2009 | 51.1 MB |
About 967 MB, covering FY1991 to present for both programs. Setting
fiscalYear routes the run to only the file covering that year - the cheapest
way to make a query fast. Fiscal years are SBA fiscal years (Oct 1 - Sep
30), so a loan approved 2020-12-07 belongs to FY2021.
Not covered: PPP (published separately as a multi-gigabyte file), EIDL, microloans, SBIC and surety bonds; borrower financials, credit scores, collateral and servicing detail; anything live - this is a quarterly snapshot, so a status reflects the extract, not today.
Input reference
From the live input schema. No input is required, and no filter field
carries a schema default that would be injected into a run you did not ask for.
program defaults to all, which widens rather than narrows. Empty means "no
filter" on every optional field, and every filter is ANDed with every other.
| Input | Type | Default | Prefill | Notes |
|---|---|---|---|---|
program | enum | all | 504 | all, 7a or 504. Routes which files are streamed. |
fiscalYear | integer | none | none | 1990-2100. Restricts to that SBA fiscal year and routes to the one file covering it. |
state | string | none | TX | 2-letter code. Matches if either borrower state or project state equals it. |
naicsPrefix | string | none | none | Keeps loans whose NAICS code starts with this prefix, e.g. 72, 23. |
lenderName | string | none | none | Case-insensitive substring match on the lender name. |
query | string | none | none | Case-insensitive substring over borrower name or lender name. Applied while the CSVs stream - it narrows, it does not speed up. |
sinceDate | string | none | none | YYYY-MM-DD only. approval_date on or after this date. |
untilDate | string | none | none | YYYY-MM-DD only. approval_date on or before this date. |
minAmount | integer | none | none | Minimum gross approval, dollars. |
order | string | none | newest | newest or oldest. Which FOIA file is read first. Empty means newest. Anything else fails the run. Orders files, not rows - see below. |
mode | enum | loans | loans | loans, rollup-by-lender, rollup-by-naics, rollup-by-state. |
flagRisk | boolean | true | none | In loans mode, compute risk_flags / risk_score. Ignored in rollups. |
maxResults | integer | 1000 | 50 | 1-200,000. The billing cap. |
maxScanRows | integer | 500000 | none | 1,000-5,000,000. Rows read per file. Not a billing cap. |
Date filters: YYYY-MM-DD only, and a bad date fails the run
sinceDate and untilDate are compared against the loan's ISO approval_date.
Only YYYY-MM-DD is accepted. An unpadded 2023-1-1 is padded for you and
logged. Anything else is rejected before any data is fetched, so a bad date
costs no result rows.
That closes a real defect: the caller's raw string used to be compared
lexicographically against ISO dates, so a wrong format was invisible in the
output - the run reported SUCCESS and billed for what it returned. Measured on
three loans approved 2020-06-15, 2023-03-01 and 2025-02-10, where the true
answer for 2023-01-01 is 2 of 3 on sinceDate: 01/15/2023 returned 3 of
3 (the whole portfolio, labelled as filtered), Jan 2023 returned 0 of 3
(a confident empty), and the impossible 2023-13-45 returned 1 of 3 - a
plausible wrong subset. On untilDate each failed the opposite way, and its
wrong-format result is a silent zero a buyer reads as a fact about SBA lending.
All now fail the run. Slash forms are rejected rather than guessed
(05/06/2025 is ambiguous between US May 6 and EU June 5, and guessing trades a
loud error for a silent one-month shift), and calendar validity is checked as
well as shape, because 2023-13-45 satisfies the pattern. An empty string
means "no bound", not an error - the schema pattern explicitly admits "", so
a caller sending an empty bound is not rejected at the platform edge.
An inverted window fails loudly. If untilDate precedes sinceDate nothing
can match; that used to return 0 rows on a SUCCEEDED run. Equal bounds are
legal - a single-day window.
Result ordering: newest file first, and what that does not mean
Results are the first N matches in read order, and the read order is now explicit, deterministic and disclosed on every row. It did not used to be.
What was wrong. The file list is resolved at run time from SBA's catalog at
https://data.sba.gov/data.json, and the Actor read the files in whatever order
the catalog listed them. Measured live on 2026-08-25, that order is:
FOIA_504_FY1991_FY2009 FOIA_504_FY2010_Present FOIA_7a_FY1991_FY1999FOIA_7a_FY2000_FY2009 FOIA_7a_FY2010_FY2019 FOIA_7a_FY2020_Present
Oldest first. So the bare, reasonable query {"program":"7a","state":"CA"}
returned California loans approved 1990-10-01 - measured on build 1.0.16, the
first five rows were KESSLER SHELL SERVICE, G & R AUTO WRECKING and three
others from FY1991. A lender, underwriter or franchise broker asking for "SBA
7(a) loans in California" wants current activity, not the Bush administration.
What changed. Files are now ordered by their fiscal-year window, newest
first, by default. The same query now returns FY2020 rows approved 2019-10-01.
The order is derived from each file's own coverage window rather than a
hard-coded list, and it is tie-broken by filename so it is a total order - a
catalog reshuffle can no longer silently change which rows a capped run returns.
Set order: "oldest" to get the previous traversal back for historical work.
What this does NOT do, measured rather than assumed. Newest-file-first is
not newest-row-first. This Actor does not sort rows. They arrive in each
file's own published order, and SBA publishes every FOIA file ascending by
ApprovalDate - measured 2026-08-25 by ranged reads across
FOIA_7a_FY2020_Present_asof_260630.csv (181,130,871 bytes):
| Position in file | ApprovalDate |
|---|---|
| head | 2019-10-01 |
| 25% | 2021-11-03 |
| 50% | 2023-09-13 |
| 75% | 2025-01-08 |
| 99% | 2026-06-05 |
| tail | 2026-06-30 |
So order: "newest" returns the oldest rows of the newest file. That moves a
bare California 7(a) query from 1990-10-01 to 2019-10-01 - a 29-year
improvement, and still not this quarter. To get a recent window you must ask
for one, with sinceDate, untilDate or fiscalYear. Every row carries
file_order and a row_order_note saying exactly this, so nobody has to infer
it from the data.
Interaction with the two caps. maxScanRows bounds reading, per file;
maxResults bounds emitting and therefore billing. Neither meaning
changed. What changed is which file the scan budget lands on first: with the
default it is spent on the most recent fiscal-year file. In rollup modes that
means a capped rollup now aggregates recent loans rather than 1990s loans, so a
charge_off_rate computed under a tight cap will look lower - a young cohort has
not had time to default. Raise maxResults and maxScanRows together, or set an
explicit window, before comparing rollups across builds.
Output fields
Every row - loan row and rollup row alike - carries source_system
(SBA data.sba.gov 7(a)/504 FOIA) plus the provenance block below, so a row can
always be traced to the extract it came from.
Source provenance (12 fields, on every row)
| Field | Meaning |
|---|---|
source_discovery | discovered when the file list came from SBA's live /data.json catalog; pinned_fallback when discovery failed and the built-in list was used. A fallback is always disclosed. |
source_files | Semicolon-separated list of every FOIA file this run selected. |
source_as_of | Vintage of the oldest file the run read. A run is only as fresh as its stalest file. |
source_as_of_days_old | Age of source_as_of at run time. Past 210 days the run fails instead. |
source_files_attempted / _read / _failed | How many FOIA files were tried, answered, and did not. |
source_files_complete | false when a selected file could not be read, so counts on this row are over a partial file set. |
source_columns_missing | Expected SBA columns the published file no longer carries; their mapped fields are null. |
file_order | newest_first (default) or oldest_first - the order the FOIA files were read in. Results are the first N matches in read order, so this decides which slice of history a capped run returns. |
row_order_note | Says plainly what the read order does and does not guarantee: files are ordered, rows are not re-sorted, and SBA publishes each file ascending by ApprovalDate. |
query_applied | Whether a non-empty query was supplied and applied. |
Loan rows (mode: "loans") - 39 fields
| Field | Meaning |
|---|---|
program | 7a or 504. |
subprogram | Null on the current extract - SBA dropped the column. See Limitations. |
processing_method | e.g. SBA Express Program, Preferred Lenders Program. A different column from subprogram, present in both extracts. |
borrower_name | Borrowing small business. |
borrower_city / borrower_state / borrower_zip | Borrower location as published. |
project_state / project_county | Location of the financed project; may differ from the borrower's. |
lender_name / lender_state | Participating bank (7(a)) or third-party lender / CDC (504), and its state. |
gross_approval / sba_guaranteed_approval | Total approved dollars, and the portion SBA guaranteed. |
approval_date / approval_fiscal_year | Approval date normalised to YYYY-MM-DD, and its SBA fiscal year. |
initial_interest_rate / term_in_months | Initial rate as published (percent), and loan term. |
naics_code / naics_description | Borrower industry code and description. |
franchise_name | Franchise name if a franchisee, else null. |
business_type | CORPORATION, INDIVIDUAL, PARTNERSHIP etc. |
jobs_supported | Jobs reported supported. Can legitimately be 0. |
loan_status | Readable label for a known SBA code, else the raw value verbatim. |
loan_status_code | The status canonicalised (upper-cased, whitespace and underscores removed), e.g. PIF. Filter on this, not the display label. |
loan_status_recognized | true for a mapped code, false for one this Actor has not seen, null when no status was published. |
loan_status_disclosed | false when SBA withheld the status (EXEMPT) or published an unmapped code; null when no status was published. |
charged_off | True when the loan is a charge-off. Strict - see Limitations. |
paid_in_full | true / false / null. Three-valued - see Limitations. |
paid_in_full_basis | How it was decided: status_code, paid_in_full_date, status_withheld, status_unrecognized, status_absent. |
paid_in_full_date / charge_off_date | SBA's published payoff and charge-off dates, where there are any. |
gross_charge_off_amount | Dollars charged off; published as 0 when not charged off. |
amount_per_job | gross_approval / jobs_supported. Null when jobs is 0. |
extract_as_of_date | The vintage stamp on the source row itself (SBA's AsOfDate). |
source_file / source_file_as_of | The CSV this loan was streamed from, and its as-of date. |
source_system | Constant source label. |
risk_flags / risk_score | Flags computed over the fetched set, and their count. |
Risk flags, computed over the set the run actually fetched:
high_amount_per_job when amount_per_job exceeds both a $500,000 floor
and mean + 3 standard deviations of that set (both must hold, so a small
sample cannot manufacture outliers); charged_off; and lender_concentration
when the lender holds more than 25% of total approved dollars in the set.
The 200-loan California run measured on build 1.0.15 produced 5, 6 and 0.
Rollup rows (rollup-by-lender / -naics / -state) - 14 fields
One row per group, sorted by total_approved descending.
| Field | Meaning |
|---|---|
rollup_dimension | lender, naics or state. |
lender_name / naics_code / state | Group key; the field name changes with the dimension. Unknown keys become (unknown lender), (unknown naics), (unknown state). |
naics_description | Industry description, on rollup-by-naics rows only. |
loan_count | Loans in the group. |
total_approved / avg_approved | Total and mean gross approved dollars. |
total_guaranteed | Total SBA-guaranteed dollars. |
charged_off_count / charge_off_rate | Charge-offs in the group, and that over loan_count (0-1, 4 places). |
total_charged_off_amount | Total dollars charged off. |
jobs_supported | Sum of jobs supported. |
avg_amount_per_job | Mean of non-null amount_per_job in the group, else null. |
paid_in_full_count | Loans in the group SBA reports as paid in full. |
status_undisclosed_count | Loans whose status SBA withheld or published under an unrecognised code. A paid-in-full share computed without this is a rate over an unknown denominator. |
Every rollup number is computed only over the loans fetched under
maxResults - not a whole-file aggregate. See Limitations.
Declared, emitted and displayed are three different sets. Across both row
kinds the Actor emits 61 distinct fields; the dataset schema declares 50;
the Store's Overview view shows a readable 21. The 11 emitted but not
declared are borrower_zip, project_county, lender_state,
initial_interest_rate, franchise_name and charge_off_date on loan rows,
plus total_guaranteed, charged_off_count, total_charged_off_amount,
avg_amount_per_job and state on rollup rows. All 61 arrive in the dataset and
in the API, JSON and CSV exports regardless - the declaration affects the Store's
rendering, not the data you receive.
Worked examples
1. California 7(a) loans with risk flags
{ "program": "7a", "state": "CA", "mode": "loans", "flagRisk": true, "maxResults": 200 }
200 rows, 10 seconds, $1.60 when measured on build 1.0.15.
Below is a real 7(a) record captured from the as-of 2026-06-30 extract on
2026-08-25 and put through the current mapper, with the provenance block a
program: "7a" run attaches (four 7(a) files selected, all read). risk_flags
and risk_score are omitted because they are computed over the whole fetched
set, not over one row.
{"program": "7a", "subprogram": null,"processing_method": "SBA Express Program","borrower_name": "AMERIPRO CONSTRUCTION SERVICES, INC.","borrower_city": "Norristown", "borrower_state": "PA", "borrower_zip": "19403","project_state": "PA", "project_county": "MONTGOMERY","lender_name": "TD Bank, National Association", "lender_state": "DE","gross_approval": 25000, "sba_guaranteed_approval": 12500,"approval_date": "2019-10-01", "approval_fiscal_year": 2020,"initial_interest_rate": 11.04, "term_in_months": 120,"naics_code": "236220","naics_description": "Commercial and Institutional Building Construction","business_type": "PARTNERSHIP", "jobs_supported": 5, "amount_per_job": 5000,"loan_status": "Paid In Full", "loan_status_code": "PIF","loan_status_recognized": true, "loan_status_disclosed": true,"charged_off": false, "gross_charge_off_amount": 0, "charge_off_date": null,"paid_in_full": true, "paid_in_full_basis": "status_code","paid_in_full_date": "2024-11-30","extract_as_of_date": "2026-06-30","source_file": "FOIA_7a_FY2020_Present_asof_260630.csv","source_file_as_of": "2026-06-30","source_discovery": "discovered", "source_as_of": "2026-06-30","source_as_of_days_old": 56,"source_files_attempted": 4, "source_files_read": 4, "source_files_failed": 0,"source_files_complete": true, "source_columns_missing": "subprogram","file_order": "newest_first","row_order_note": "FOIA files are read newest first (order=\"newest\"). Rows are emitted in each source file's own published order and are NOT re-sorted by this Actor: SBA publishes every FOIA file ascending by ApprovalDate (measured 2026-08-25), so within one file the oldest rows of that file come first. Use sinceDate / untilDate / fiscalYear to place the window deliberately.","query_applied": false}
loan_status reads Paid In Full and paid_in_full is true: SBA writes
P I F in the 7(a) files, which canonicalises to PIF. subprogram is null,
and source_columns_missing says why. A withheld status on the same extract
instead reads loan_status: "Exempt (status withheld by SBA)",
loan_status_disclosed: false, paid_in_full: null (never false) and
paid_in_full_basis: "status_withheld".
Live: https://apify.com/malonestar/sba-loan-portfolio-explorer/examples/sba-7a-loans-california
2. Texas 7(a) lender league table
{ "program": "7a", "state": "TX", "mode": "rollup-by-lender", "maxResults": 100 }
100 loans fetched, collapsed to 52 lender rows, 7 seconds, $0.42, measured on build 1.0.15 against the as-of 2026-03-31 extract. Top row as measured:
{"rollup_dimension": "lender", "lender_name": "The Huntington National Bank","loan_count": 13, "total_approved": 7485500, "avg_approved": 575807.69,"total_guaranteed": 5593975, "charged_off_count": 0, "charge_off_rate": 0,"jobs_supported": 170, "avg_amount_per_job": 95259.54}
On the current build the same row also carries paid_in_full_count,
status_undisclosed_count and the full 12-field provenance block - a rollup is
where a stale or partially-read file set would otherwise be invisible.
Both published example datasets above predate the source refresh, so their
rows carry the as-of 2026-03-31 extract; a run started today resolves the
current extract and says so on every row.
Live: https://apify.com/malonestar/sba-loan-portfolio-explorer/examples/top-sba-7a-lenders-in-texas
3. One industry, one fiscal year, above a size floor
{"program": "7a", "fiscalYear": 2023, "naicsPrefix": "72", "minAmount": 250000,"sinceDate": "2023-01-01", "untilDate": "2023-06-30","mode": "rollup-by-naics", "maxResults": 5000, "maxScanRows": 2000000}
fiscalYear routes the run to a single file; the raised maxScanRows lets the
stream read long enough to find the matches without changing what you are
billed. Cost stays bounded by maxResults.
Failure behaviour
Fails before fetching, billing no result rows: any date that is not
YYYY-MM-DD, and an inverted window (see above). A value the input schema's own
pattern rejects is refused by the platform before the run object exists, so not
even actor-start is charged. Likewise a program / fiscalYear combination no
FOIA file covers, a file list that cannot be resolved, and an extract older than
the 210-day freshness fuse all fail before a byte is streamed.
Fails after starting, billing nothing: an upstream error that does not
throw. A 404 or 5xx from data.sba.gov, a file whose published header has lost
a load-bearing column, and a stream that drops mid-file all end the run with a
failure naming the file and the status. A dropped socket used to be reported as
a clean end-of-file, turning an interrupted read into "SBA has no more matching
loans"; a truncated stream is now a failure, not an answer. An outage is never
reported as "no loans matched your filters."
Partial reads answer, but say so. If some selected files answer and others
do not, the run succeeds over what it could read and tags every row
source_files_complete: false with source_files_read / source_files_failed.
Check that before treating a rollup as an aggregate of everything.
A zero can still be the answer. When every selected file was read and no loan matched, that is a SUCCEEDED run with 0 rows and a log line saying so. A 0-row run bills no results.
The null contract. Null means SBA published no value in that column for that
loan - the Actor does not distinguish "not published" from "not applicable" -
except for paid_in_full, where null means SBA did not disclose a usable
status and paid_in_full_basis says which case applied. There is no truncation
flag: a result set that hit maxResults looks exactly like one that did not, so
compare the row count against your maxResults.
Limitations and honest boundaries
Rollups aggregate the fetched sample, not the whole file, and results are
the first N matches in read order - no ranking, no sampling, and no
row-level sorting. The Texas example is a league table of the first 100
matching Texas 7(a) loans, not of every one ever approved; in the 200-row
California run measured on build 1.0.15, every loan returned fell inside a
ten-day approval window because the stream stopped at 200 matches. Raise
maxResults until the row count stops growing, raise maxScanRows with it, and
use fiscalYear, sinceDate and untilDate to place the window deliberately.
The read order used to be incidental, and it was oldest-first. Files were
read in whatever order SBA's catalog listed them, which put FY1991 first, so a
bare {"program":"7a","state":"CA"} answered with loans approved 1990-10-01.
Files are now read newest-first by default, the order is total and
deterministic rather than catalog-dependent, and file_order reports it on every
row. This is a behaviour change: a caller who was getting 1990 loans now gets
FY2020 loans. order: "oldest" restores the previous traversal exactly. And it
orders files, not rows - see "Result ordering" above for the measurement and
for why you still need sinceDate / fiscalYear to get a genuinely recent
window.
SBA dropped the subprogram column in the as-of 2026-06-30 extract. The
previous extract carried it (Guaranty, FA$TRK (Small Loan Express), ...);
the current one does not, so subprogram is null and the loss is reported on
every row in source_columns_missing rather than left as an unexplained blank.
ProcessingMethod exists in both extracts and is a different vocabulary
(SBA Express Program, Preferred Lenders Program, 7a General), so it ships
as its own processing_method field and is deliberately not substituted
into subprogram - a plausible value in the wrong column is worse than an
honest null. If you depend on subprogram it is gone until SBA republishes it,
and source_columns_missing is what will tell you when it returns.
paid_in_full is three-valued, and null does not mean "no". SBA's 7(a)
files write the status as P I F with spaces while the 504 file writes
PIF - both in the same extract - so statuses are matched on a canonical code,
never a raw literal. Fixed precedence: a recognised, definitive code wins
outright (true for PIF; false for charged off, cancelled, current,
delinquent, past due, in liquidation, deferred, committed, closed or
purchased-not-charged-off), so a charged-off loan carrying a stray
PaidInFullDate cannot be flipped to paid in full. Otherwise a published
PaidInFullDate proves true (basis paid_in_full_date), which is how an
EXEMPT loan that still shows a payoff date resolves. Otherwise null, with
the reason on the row: status_withheld (EXEMPT, withheld under FOIA - the
loan may be open, paid or in liquidation and the file does not say which),
status_unrecognized, or status_absent. null is never a verified
negative - do not read it as false, and do not compute a paid-in-full rate
without status_undisclosed_count in the denominator.
loan_status is an observed vocabulary, not an exhaustive one, and it changes
between extracts. Measured 2026-08-25 by tallying LoanStatus across ranged
slices of the published files: the as-of 2026-06-30 extract used P I F, PIF,
EXEMPT, CANCLD, CHGOFF and COMMIT; the as-of 2026-03-31 extract also
used CURR, LIQUID, PURCH(NOT C/O), DELINQ, PSTDUE, CLSLN and
DEFERD. Those thirteen spellings collapse to twelve canonical codes, all
mapped, plus a thirteenth (NOTFUNDED) that neither slice used. This is what
appeared in the slices actually read, not a list SBA publishes, so a new code
is expected eventually. It then passes through verbatim, sets
loan_status_recognized: false, and forces paid_in_full to null rather than
false - a vocabulary that is not closed cannot support a confident negative.
charged_off is strict, and a proxy. True only when the status maps to
CHGOFF, or a charge-off date or positive charge-off amount is published -
loans in liquidation, delinquent, past due or purchased-but-not-yet-charged-off
are not counted. So charge_off_rate is a conservative realised-loss proxy,
not a default rate, and it is a lifetime figure: a rollup mixing FY1995 and
FY2024 loans flatters the young cohort, which has not had time to default.
amount_per_job is null, not zero, when a loan reports 0 jobs. In the
200-row California run measured on build 1.0.15, 27 rows reported
jobs_supported: 0 and all 27 carried amount_per_job: null; they are excluded
from avg_amount_per_job and the outlier statistics rather than treated as
infinite.
query narrows the answer, it does not speed up the scan. data.sba.gov
exposes no query API, so query is a client-side substring match over borrower
name and lender name, ANDed with every other filter and confirmed per row by
query_applied. It previously accepted the value and never applied it,
returning the unfiltered answer on a successful run.
File discovery, data currency, and the freshness fuse. The file list is
resolved at run time from SBA's catalog at https://data.sba.gov/data.json. SBA
has published these files under two naming conventions - hyphenated under
/distribution/SBA-OHA-2016-08-001/ and underscored under
/uploaded_resources/ - and both are recognised, because a matcher that knew
only the older convention silently served a two-quarter-old extract while the
refresh sat published and every request returned HTTP 200. Three guards: a
catalog that yields nothing falls back to the pinned list and every row says
so (source_discovery: "pinned_fallback"), never a silent substitution; an
extract more than 210 days old fails the run and bills nothing rather
than selling frozen data; and only the current extract is pinned, because the
legacy copies still answer HTTP 200 with the older extract and a fallback that
answers happily with stale rows is worse than none.
The 210-day fuse is calibrated on a single measured publication lag, which is its weakness. SBA published the as-of 2026-06-30 extract on 2026-07-21, a 21-day lag; 210 days is two quarterly cycles plus that lag. That is one observation, not a distribution. If SBA badly delays a quarter, a legitimately current file set could trip the fuse and fail a run that should have succeeded. The failure is loud and names the as-of date and the age, so the fix is obvious - but it is a real trade, made deliberately in favour of never selling frozen data quietly.
This is not a credit decision, a lender scorecard or a regulatory filing. It is public FOIA data, normalised and aggregated. Charge-off history reflects SBA's published loan status, not a lender's credit quality, safety and soundness, or CRA performance.
Use as an MCP tool
Callable directly by any MCP-compatible AI agent through Apify's hosted MCP server - no server to run and no integration code, because the tool schema an agent sees is generated from this Actor's own input and dataset schemas.
https://mcp.apify.com?tools=malonestar/sba-loan-portfolio-explorer
Claude Desktop, Claude Code or Cursor - add to claude_desktop_config.json,
.mcp.json or .cursor/mcp.json respectively:
{"mcpServers": {"apify": {"url": "https://mcp.apify.com?tools=malonestar/sba-loan-portfolio-explorer","headers": { "Authorization": "Bearer YOUR_APIFY_TOKEN" }}}}
Get a token at https://console.apify.com/settings/integrations. Claude Desktop
can also authenticate via OAuth against https://mcp.apify.com with no
headers block. Reference: https://docs.apify.com/platform/integrations/mcp
Try asking your agent: "Roll up SBA 7(a) lending in Texas by lender for
fiscal year 2023, cap it at 2000 loans, and tell me which five lenders funded
the most dollars and what their charge-off rates were." Agents get date formats
wrong more often than humans do, which is why a non-YYYY-MM-DD date fails the
run instead of being guessed at.
Chains well with - comma-separate the tools parameter and the agent can
carry results from one into the next:
https://mcp.apify.com?tools=malonestar/sba-loan-portfolio-explorer,malonestar/fdic-ncua-health-rollup,malonestar/bank-enforcement-tracker,malonestar/sos-registry-monitor
Billing is unchanged when called as an MCP tool: this Actor is Pay-Per-Event and an agent pays the same per-result price a human does.
FAQ
Where does the data come from? SBA's official 7(a) & 504 FOIA loan-level
files on data.sba.gov, dataset SBA-OHA-2016-08-001, streamed as published
CSVs. Keyless: no API key or login.
Does it include PPP? No - PPP ships separately as a multi-gigabyte file, as do EIDL, microloans and SBIC. Coverage here is FY1991 to present, 7(a) and 504.
Why did my rollup only cover part of the state? Rollups aggregate the loans
fetched under maxResults, in file order. Raise maxResults (and maxScanRows
with it) until the row count stops changing.
Will raising maxScanRows cost me more? No. maxScanRows bounds rows read;
maxResults bounds rows emitted and billed. Only the second touches your bill.
Why is paid_in_full null on some loans? SBA did not disclose a usable
status - most often EXEMPT, withheld under FOIA. Null means "not known", not
"no". (This used to read false on every 7(a) row: the mapping matched PIF
while the 7(a) files write P I F with spaces.)
How do I know I got the current quarterly extract? Read source_as_of and
extract_as_of_date on any row - the second comes from SBA's own AsOfDate
column, not from a filename parse, so a stale mirror cannot fake it.
source_discovery tells you whether the list came from SBA's live catalog or
the built-in fallback.
Related Actors
| Actor | What it adds |
|---|---|
| Bank & Credit-Union Financial Health API | FDIC capital, asset quality and CRE concentration for the banks making these loans |
| Bank Enforcement Action Tracker | Formal OCC / FDIC / Federal Reserve enforcement actions against a lender |
| HMDA Fair Lending Disparity Analysis | Mortgage denial and pricing disparities by protected class |
| FDIC Deposit Market Share & HHI Rollup | Branch deposit share and market concentration by county and MSA |
| US Business Entity Search & New-Business Monitor | Secretary-of-State registrations for the borrowers behind the loans |
| KYB Company Verifier | Cross-registry verification for diligence on a named borrower |
| City Business License Leads | New business licence filings as a forward-looking lending lead feed |
| FHLBank Membership Roster & Delta Monitor | Federal Home Loan Bank membership joins, exits and changes |
Pricing
Pay per result: $8.00 per 1,000 rows ($0.008 each), with a graduated 20-70%
discount on paid Apify plans. One billable row is one loan record or one rollup
group. You are billed only for rows actually written to the dataset - a run
rejected on a bad date, or one that matches nothing, bills no results. Cap any
run with maxResults.