SBA Loan Portfolio Explorer API — 7(a) & 504 Rollups avatar

SBA Loan Portfolio Explorer API — 7(a) & 504 Rollups

Pricing

from $4.40 / 1,000 results

Go to Apify Store
SBA Loan Portfolio Explorer API — 7(a) & 504 Rollups

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

Kyle Maloney

Maintained by Community

Actor stats

0

Bookmarked

4

Total users

1

Monthly active users

21 days ago

Last modified

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:

InputWhat it boundsAffects your bill?
maxResultsMatching loans fetched, so rows emitted and billedYes - the billing cap
maxScanRowsCSV rows read per file, matching or notNo - 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.

InputRows emittedRuntimeCost
{program:"7a", state:"CA", mode:"loans", maxResults:200}200 loans10 s$1.60
{program:"7a", state:"TX", mode:"rollup-by-lender", maxResults:100}52 lender rows7 s$0.42
{program:"all", mode:"loans", maxResults:1000}1000 loans29 s$8.00
Console prefill (504, TX, 50)50 loans4 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:

ProgramCoverageSizeProgramCoverageSize
7(a)FY2020 - present172.7 MB7(a)FY2000 - FY2009303.7 MB
7(a)FY2010 - FY2019243.3 MB7(a)FY1991 - FY1999139.6 MB
504FY2010 - present56.7 MB504FY1991 - FY200951.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.

InputTypeDefaultPrefillNotes
programenumall504all, 7a or 504. Routes which files are streamed.
fiscalYearintegernonenone1990-2100. Restricts to that SBA fiscal year and routes to the one file covering it.
statestringnoneTX2-letter code. Matches if either borrower state or project state equals it.
naicsPrefixstringnonenoneKeeps loans whose NAICS code starts with this prefix, e.g. 72, 23.
lenderNamestringnonenoneCase-insensitive substring match on the lender name.
querystringnonenoneCase-insensitive substring over borrower name or lender name. Applied while the CSVs stream - it narrows, it does not speed up.
sinceDatestringnonenoneYYYY-MM-DD only. approval_date on or after this date.
untilDatestringnonenoneYYYY-MM-DD only. approval_date on or before this date.
minAmountintegernonenoneMinimum gross approval, dollars.
orderstringnonenewestnewest or oldest. Which FOIA file is read first. Empty means newest. Anything else fails the run. Orders files, not rows - see below.
modeenumloansloansloans, rollup-by-lender, rollup-by-naics, rollup-by-state.
flagRiskbooleantruenoneIn loans mode, compute risk_flags / risk_score. Ignored in rollups.
maxResultsinteger1000501-200,000. The billing cap.
maxScanRowsinteger500000none1,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_FY1999
FOIA_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 fileApprovalDate
head2019-10-01
25%2021-11-03
50%2023-09-13
75%2025-01-08
99%2026-06-05
tail2026-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)

FieldMeaning
source_discoverydiscovered 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_filesSemicolon-separated list of every FOIA file this run selected.
source_as_ofVintage of the oldest file the run read. A run is only as fresh as its stalest file.
source_as_of_days_oldAge of source_as_of at run time. Past 210 days the run fails instead.
source_files_attempted / _read / _failedHow many FOIA files were tried, answered, and did not.
source_files_completefalse when a selected file could not be read, so counts on this row are over a partial file set.
source_columns_missingExpected SBA columns the published file no longer carries; their mapped fields are null.
file_ordernewest_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_noteSays 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_appliedWhether a non-empty query was supplied and applied.

Loan rows (mode: "loans") - 39 fields

FieldMeaning
program7a or 504.
subprogramNull on the current extract - SBA dropped the column. See Limitations.
processing_methode.g. SBA Express Program, Preferred Lenders Program. A different column from subprogram, present in both extracts.
borrower_nameBorrowing small business.
borrower_city / borrower_state / borrower_zipBorrower location as published.
project_state / project_countyLocation of the financed project; may differ from the borrower's.
lender_name / lender_stateParticipating bank (7(a)) or third-party lender / CDC (504), and its state.
gross_approval / sba_guaranteed_approvalTotal approved dollars, and the portion SBA guaranteed.
approval_date / approval_fiscal_yearApproval date normalised to YYYY-MM-DD, and its SBA fiscal year.
initial_interest_rate / term_in_monthsInitial rate as published (percent), and loan term.
naics_code / naics_descriptionBorrower industry code and description.
franchise_nameFranchise name if a franchisee, else null.
business_typeCORPORATION, INDIVIDUAL, PARTNERSHIP etc.
jobs_supportedJobs reported supported. Can legitimately be 0.
loan_statusReadable label for a known SBA code, else the raw value verbatim.
loan_status_codeThe status canonicalised (upper-cased, whitespace and underscores removed), e.g. PIF. Filter on this, not the display label.
loan_status_recognizedtrue for a mapped code, false for one this Actor has not seen, null when no status was published.
loan_status_disclosedfalse when SBA withheld the status (EXEMPT) or published an unmapped code; null when no status was published.
charged_offTrue when the loan is a charge-off. Strict - see Limitations.
paid_in_fulltrue / false / null. Three-valued - see Limitations.
paid_in_full_basisHow it was decided: status_code, paid_in_full_date, status_withheld, status_unrecognized, status_absent.
paid_in_full_date / charge_off_dateSBA's published payoff and charge-off dates, where there are any.
gross_charge_off_amountDollars charged off; published as 0 when not charged off.
amount_per_jobgross_approval / jobs_supported. Null when jobs is 0.
extract_as_of_dateThe vintage stamp on the source row itself (SBA's AsOfDate).
source_file / source_file_as_ofThe CSV this loan was streamed from, and its as-of date.
source_systemConstant source label.
risk_flags / risk_scoreFlags 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.

FieldMeaning
rollup_dimensionlender, naics or state.
lender_name / naics_code / stateGroup key; the field name changes with the dimension. Unknown keys become (unknown lender), (unknown naics), (unknown state).
naics_descriptionIndustry description, on rollup-by-naics rows only.
loan_countLoans in the group.
total_approved / avg_approvedTotal and mean gross approved dollars.
total_guaranteedTotal SBA-guaranteed dollars.
charged_off_count / charge_off_rateCharge-offs in the group, and that over loan_count (0-1, 4 places).
total_charged_off_amountTotal dollars charged off.
jobs_supportedSum of jobs supported.
avg_amount_per_jobMean of non-null amount_per_job in the group, else null.
paid_in_full_countLoans in the group SBA reports as paid in full.
status_undisclosed_countLoans 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.

ActorWhat it adds
Bank & Credit-Union Financial Health APIFDIC capital, asset quality and CRE concentration for the banks making these loans
Bank Enforcement Action TrackerFormal OCC / FDIC / Federal Reserve enforcement actions against a lender
HMDA Fair Lending Disparity AnalysisMortgage denial and pricing disparities by protected class
FDIC Deposit Market Share & HHI RollupBranch deposit share and market concentration by county and MSA
US Business Entity Search & New-Business MonitorSecretary-of-State registrations for the borrowers behind the loans
KYB Company VerifierCross-registry verification for diligence on a named borrower
City Business License LeadsNew business licence filings as a forward-looking lending lead feed
FHLBank Membership Roster & Delta MonitorFederal 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.