Multi-Portfolio Routing
Run multiple programs — prime card, subprime, BNPL Pay-in-4, auto, mortgage, multi-state — from a single Metro2 company. Each program reports to its own set of bureaus, on its own schedule, against its own validation profile.
What it is
Multi-Portfolio Routing replaces the implicit “one company → all three bureaus → same monthly schedule” model with an explicit entity called a Portfolio. A portfolio is a named group of accounts that share a single reporting policy: which bureaus to send to, in which file format, on which cadence, and against which validation profile.
Around portfolios sits a small routing-rule engine that decides which portfolio a new record belongs to. Rules are plain JSON conditions over fields like account_type, state, or ecoa_code. The engine runs the first time a record is imported and remembers the result on the row (the portfolio_id column). After that, every downstream step — file generation, transmission, validation, reconciliation — respects the assignment.
The architecture is deliberately additive. Nothing about the legacy one-company-one-pipeline behaviour was removed. The single-portfolio path is still the default for every customer, and the rule engine is a pure function with extensive unit coverage so you can audit exactly why a record went where it went.
Why bother? Because misrouting a tradeline is the headline compliance risk in modern furnishing. A Pay-in-4 BNPL account does not belong in a furnisher’s core file. A subprime small-dollar loan should reach Clarity but not necessarily Experian core. A California-only program may need to honour state-specific cycle timing. Portfolios give you a place to encode those policies as data instead of as tribal knowledge inside a spreadsheet.
Why you’d use it
A single furnishing pipeline works fine if your business reports one product to one set of bureaus on one schedule. The moment that breaks — even mildly — you start to need portfolios. A few real scenarios from the field:
Prime card + subprime card under one entity
A bank issues two credit card products from the same legal entity. The Prime Card goes to Equifax, Experian, and TransUnion on the 5th of every month. The Subprime Card goes to TransUnion and Clarity only, on the 20th, and uses a stricter validation profile that flags missing collateral fields. Same Metro2 company, two portfolios, two schedules, two route sets, two profiles. No spreadsheet juggling.
BNPL Pay-in-4 alongside an installment book
A fintech reports traditional installment loans to Equifax / Experian / TransUnion and runs a Pay-in-4 BNPL product. BNPL accounts must go to the Equifax BNPL exchange (equifax_bnpl) and the TransUnion BNPL partition (transunion_bnpl), not to the core files. The BNPL portfolio uses the metro2_bnpl file format, is tagged with BIC=BN, and has a routing rule that captures any record with account_type=07. The installment portfolio uses the standard metro2 format and inherits the company default.
Multi-state lender with different cycles
A consumer lender operates nationally but is required to delay reporting in California by an extra cycle because of an internal dispute-review SLA tied to SB-1157. The lender creates two portfolios: National (cycle: monthly on the 5th) and California (cycle: monthly on the 20th, plus a stricter validation profile). One routing rule with state == "CA" peels off the California accounts; everything else falls through to the National portfolio via the default fallback.
Mortgage furnishing carved out of a multi-product portfolio
Mortgage accounts (account types 25 / 26 / 27) require K3-segment handling that does not apply to credit cards or auto loans. Carving them into a Mortgage portfolio lets you attach a validation profile that requires the segment, and prevents a card-shaped record from ever reaching the mortgage file.
Servicer with multiple sub-clients
A loan servicer reports on behalf of three originators that each want to appear under their own subscriber code. The servicer runs one Metro2 company with three portfolios, one per originator, each with its own bureau routes and subscriber codes. Imports are tagged with a metadata field (e.g. metadata.originator) and a routing rule maps each originator to its portfolio.
The unifying pattern is: different policies for different slices of accounts, without spinning up an entirely new Metro2 tenant for each one.
Migration story: nothing changes unless you opt to split
If you had a Metro2 company before portfolios shipped, the rollout is a no-op for you. As part of the deployment we ran a backfill that:
- Created exactly one portfolio per company, named “Default” and flagged
is_default=true. - Copied each company’s existing bureau toggles (
companies.report_to_equifax / report_to_experian / report_to_transunion) into bureau routes on that default portfolio. - Stamped
portfolio_idon every existing Metro2 record, submission schedule, transmission, and validation result so the new code paths have a portfolio to anchor to. - Pointed the default portfolio at the system “Default” validation profile, which is a 1:1 mirror of the 56 built-in rules.
The numbers from the rollout: 147 existing companies were backfilled to 1 default portfolio each, 7,501 historical records were stamped, and zero records ended up unrouted. The cycle-1 zero-change contract was held: the file your company generated for the cycle after rollout is byte-identical to the file it would have generated before rollout.
You don’t need to do anything. You can keep operating with your single Default portfolio indefinitely. The features in the rest of this guide apply the moment you decide to add a second portfolio — and only then.
Concepts
Three nouns do almost all the work in this system. If you remember these, everything else falls out:
Portfolio
A named group of accounts that share a single reporting policy. A portfolio holds:
- A name and a slug (URL-safe identifier).
- Jurisdiction:
USorCA(Canadian Metro2). - Default portfolio type and account type CDIA codes applied when an imported record doesn’t supply them.
- An optional override for reporter identity (subscriber name, address, phone) that takes precedence over the company-wide defaults when files are generated.
- A validation profile (which 56-rule subset and which optional Pro+ custom rules apply).
- A boolean
is_specialty_fileflag that turns on extra preflight checks for specialty files like BNPL. - A boolean
is_defaultflag — exactly one portfolio per company carries this; it is the fallback target for any record that matches no rule.
Bureau Route
One row of the table portfolio_bureau_routes. Think of it as “this portfolio sends to this bureau in this format on this schedule.” The composite key is (portfolio × bureau × file_format × schedule). A bureau route holds:
- The destination bureau:
equifax,experian,transunion,clarity,equifax_bnpl,transunion_bnpl, etc. The bureau registry is extensible. - A file_format:
metro2(standard),metro2_bnpl(Pay-in-4 specialty),metro2_subprime_ext(Clarity extension). - An optional subscriber_code override (rare; most customers inherit from the portfolio or company).
- An optional SFTP settings reference (
sftp_settings_id) for per-route delivery. The schema is in place; automated SFTP upload from individual routes is on the roadmap but not wired through end-to-end yet, so today the route attribute is informational. - An enabled flag — flip a route off without deleting it.
Routing Rule
A condition tree that picks a portfolio for a new record. Rules are stored as JSON and evaluated by a pure function. Each rule has:
- A name (human-readable, surfaced in audit logs).
- A priority: an integer, evaluated ascending. Lower numbers run first; on tie, the most-recently-created rule wins.
- A conditions JSON block written in the DSL described below (
all/any/notcombinators wrapping leaf comparisons). - A target
portfolio_id. - An enabled flag.
How they fit together
A record arrives through import. The rule engine walks rules in priority order; the first matching, enabled rule wins and the record is stamped with that portfolio’s id and portfolio_assigned_by='rule'. If no rule matches, the record falls through to the company’s default portfolio and is stamped with portfolio_assigned_by='default'. If a human re-assigns the record manually from the UI, it is stamped with portfolio_assigned_by='manual' and from that moment on the rule engine will not touch it — manual overrides win forever (until a human clears them).
When file generation runs, it walks the portfolio’s bureau routes and emits one file per route, including only the records stamped with that portfolio’s id.
Creating a portfolio
The new-portfolio wizard lives at /dashboard/portfolios/new. You can also reach it from the Portfolios nav item with the + New portfolio button.
Step 1 — Pick a template
The wizard opens on a grid of six templates. A template preloads sensible defaults for the portfolio, its bureau routes, an optional routing rule, and a validation profile suggestion. You can edit everything after creation; templates exist to save typing.
| Template | What it sets up | When to pick it |
|---|---|---|
| Prime Card | Routes to Equifax, Experian, TransUnion in standard metro2 format. Portfolio type R (revolving), account type 07. | Plain-vanilla prime credit card program reporting to all three majors. |
| Subprime / Clarity | Routes to Equifax, TransUnion, and Clarity (with metro2_subprime_ext). Portfolio type I (installment), specialty-file flag on, Subprime validation profile. | Short-term / subprime lending where Clarity is part of your distribution. |
| BNPL Pay-in-4 | Routes to equifax_bnpl + transunion_bnpl in metro2_bnpl format. Tags BIC=BN. Adds a seed routing rule “account_type == 07 → BNPL.” Specialty-file flag on. BNPL validation profile. | Pay-in-4 buy-now-pay-later tradelines that must not reach the core file. |
| Auto Loans | Routes to all three majors. Default account type 00. Seed rule captures account types 00 / 10 / 11. | Carving auto loans out of a mixed portfolio so you can apply auto-specific validation later. |
| Mortgage | Routes to all three majors. Default portfolio type M, account type 26. Seed rule captures account types 25 / 26 / 27. | Mortgage furnishing with K3-segment requirements (paired with the Mortgage industry validation profile). |
| CA Multi-state | Routes to all three majors. Seed rule: state == "CA". | Carve California-resident accounts off for state-specific cycle timing or stricter validation. |
If none of the templates fit, choose the closest one and edit it, or click Start from scratch at the bottom of the grid.
Step 2 — Name and identity
Each portfolio needs a name (up to 120 characters, shown everywhere in the UI) and a slug (lower-case, alphanumeric, hyphens; up to 63 characters; appears in URLs and storage paths). The wizard pre-fills both from the template label.
Optional identity overrides on this step:
- Reporter name / address / phone — what appears in the HEADER record. Defaults to the company-wide values.
- Text file prefix / Zip file prefix — applied when files are generated and downloaded, useful when you want the filename to reflect the program (e.g.
LEVY_BNPL_). - Business Industry Code (BIC) — e.g.
BNfor BNPL. Required for specialty files; the preflight check refuses to ship a specialty file without it. - Cycle identifier — one-or-two character code emitted into the file header to distinguish cycles.
Step 3 — Jurisdiction
Pick US or CA. This drives which CDIA field rules the validator applies and which date format the generator emits. The default is the company-wide jurisdiction.
Step 4 — Validation profile
Pick the validation profile this portfolio should be evaluated against. The dropdown lists every system-preset profile (e.g. Default, BNPL, Subprime) plus any custom profiles your company has authored. See Validation profiles for the inheritance rules.
Step 5 — Review and create
The last screen shows a JSON-style summary of what will be created: the portfolio, its bureau routes, and the seed routing rule (if any). Click Create portfolio and the wizard POSTs to /api/portfolios and follow-up requests to /api/portfolios/{id}/bureau-routes and /api/portfolios/{id}/routing-rules. On success you’re redirected to the portfolio detail page.
A common pitfall: the wizard’s seed routing rule is created at a default priority (1000). If you already have rules in play, double-check the priorities so your new rule doesn’t get starved out by an earlier rule that also matches. See Routing rules for priority semantics.
Managing bureau routes
Open a portfolio at /dashboard/portfolios/{id} and click the Routes tab. You’ll see a table with one row per bureau route, plus an + Add route button.
Adding a route
Each route asks for:
- Bureau — pick from the bureau registry. The shipped registry is seeded with
equifax,experian,transunion,clarity,equifax_bnpl,transunion_bnpl. Specialty endpoints (Pay-in-4) are disabled in the dropdown unless the portfolio is flagged as a specialty file. - File format — one of
metro2,metro2_bnpl,metro2_subprime_ext. The dropdown filters to the formats supported by the selected bureau. - Subscriber code (optional) — only set this if this route uses a different subscriber identifier from the company default. Most portfolios leave this blank.
- Schedule — pick an existing submission schedule (cycle 1, monthly, weekly, custom cron) or create a new one inline. The schedule is the cadence on which files for this route will be generated by the cron worker.
- SFTP settings (optional) — route-level override for where the file is delivered. Storage of the override is wired through; automated SFTP upload from individual routes is on the roadmap but not yet enabled, so today this field is informational. Cycle-managed customers continue to deliver via the company-wide SFTP path.
- Enabled — default
true.
Example route table
A BNPL Pay-in-4 portfolio typically looks like this:
| Bureau | File format | Schedule | Subscriber code | Enabled |
|---|---|---|---|---|
equifax_bnpl | metro2_bnpl | Monthly — 5th | (inherits from portfolio) | Yes |
transunion_bnpl | metro2_bnpl | Monthly — 5th | (inherits from portfolio) | Yes |
Crucially, this portfolio has no equifax / experian / transunion rows — that’s the whole point. Pay-in-4 accounts should not appear in the core files and the absence of those routes guarantees they won’t be emitted.
Editing or removing a route
Click the pencil icon on a row to edit it (file format, schedule, subscriber code, SFTP override) or the trash icon to remove it. A removed route does not delete historical transmissions or files — it just stops new files from being generated for that (portfolio × bureau) pair.
If you want to silence a route temporarily (say, while you debug a rejection), flip enabled off instead of deleting it. The cron job skips disabled routes; everything else (audit, UI history) keeps working.
Routing rules
Routing rules pick the portfolio for each new record. They’re stored as JSON and evaluated by a pure function with extensive unit-test coverage, because misrouting is the headline compliance risk. The rule editor lives on the Rules tab of each portfolio.
The condition DSL
A rule’s conditions field is a tree of groups and leaf conditions.
- A group has one of three combinator keys:
all(logical AND of children),any(logical OR), ornot(single child, negated). Groups can nest. - A leaf condition is an object with three keys:
field,op, andvalue.
The simplest possible rule, matching any account with type 07:
{
"all": [
{ "field": "account_type", "op": "eq", "value": "07" }
]
}A rule that captures California accounts whose balance is at least $5,000:
{
"all": [
{ "field": "state", "op": "eq", "value": "CA" },
{ "field": "current_balance", "op": "gte", "value": 500000 }
]
}(Balances are stored in cents at the database level.) A rule that captures account types in a set and excludes joint accounts:
{
"all": [
{ "field": "account_type", "op": "in", "value": ["25", "26", "27"] },
{ "not": { "field": "ecoa_code", "op": "eq", "value": "2" } }
]
}A rule that captures BNPL or a metadata-flagged originator:
{
"any": [
{ "field": "account_type", "op": "eq", "value": "07" },
{ "field": "metadata.originator_id", "op": "eq", "value": "skucorp" }
]
}Operators
Thirteen operators are supported:
| Operator | Meaning | Value shape |
|---|---|---|
eq | Case-insensitive equality. | string or number |
neq | Case-insensitive inequality. | string or number |
in | Field value is in the supplied array (case-insensitive). | array of string/number |
not_in | Field value is NOT in the supplied array. | array of string/number |
gt | Greater than. Numeric if both sides coerce cleanly to numbers; otherwise lexicographic. | number or string |
gte | Greater than or equal to. Same coercion as gt. | number or string |
lt | Less than. Same coercion as gt. | number or string |
lte | Less than or equal to. | number or string |
starts_with | String prefix match (case-insensitive). | string |
ends_with | String suffix match (case-insensitive). | string |
regex | JavaScript regular expression test. Invalid patterns fail closed (return false). | string (regex pattern) |
exists | Field is present and not null, undefined, or empty string. | ignored |
not_exists | Field is missing, null, undefined, or empty. | ignored |
Fields you can reference
The supported field list is intentionally tight in v1 to limit blast radius:
account_typeportfolio_typestatecountry_codeecoa_codeaccount_statusconsumer_account_numbercurrent_balancecredit_limitamount_past_duedate_openedmetadata.<key>— one level deep into the record’smetadataJSON column. Useful for originator IDs, internal program codes, A/B-test buckets, etc.
Priority and tie-breaking
Rules are sorted by priority ascending(so 100 runs before 200 runs before 300). If two rules share a priority, the more recently created rule wins. This matches the typical mental model: “I just edited this rule, it should take effect.”
A pattern that works well in practice:
- Reserve priorities
0–99for hard-block / very specific rules. - Use
100–999for normal program rules. - Leave the
1000+range to catch-all / fallback rules and the template-seeded default priority.
Manual override precedence
When a human manually re-assigns a record from a portfolio, the row is stamped with portfolio_assigned_by='manual'. From that moment on, the rule engine refuses to re-route the record. A manual override beats every rule, regardless of priority, and has no expiry by default. To clear it, click Reset to rule on the record detail page; the next ingest pass will re-evaluate rules against the row.
The PortfolioRuleBuilder UI
The visual rule builder lets you edit rules without hand-writing JSON. Add a condition, pick a field from the dropdown, pick an operator, type the value. Nest with + Group buttons. Switch between ALL / ANY at the group header.
The right-hand panel of the builder shows a live preview count: the number of records in your current dataset that would match the rule as drafted. The preview is backed by the same pure evaluator the production pipeline uses, so the count is exact, not an estimate. Use this to sanity-check a rule before saving — if you expected ~3,000 BNPL records to match and the preview says 12, your condition is wrong.
Hit Save to POST / PUT the rule. Hit Preview against all records to call /preview (see the API reference) and see a full distribution: how many records each portfolio would receive, how many would fall back to the default, how many would be unrouted.
Manual override
Occasionally you’ll need to pin a single record to a portfolio regardless of what the rules say. Common reasons: a one-off legal settlement, a research account that should never go to a specific bureau, a record imported with bad data that you’ve corrected in place.
Open the record detail page, click the Portfolio dropdown, and pick a different portfolio. The row is updated with the new portfolio_id and portfolio_assigned_by='manual'.
Key properties:
- Manual overrides do not expire. Once set, the record stays where you put it forever. This is a deliberate design choice from
DECISIONS.md— auto-expiring overrides cause “why did my settlement account suddenly re-appear in the core file?” bugs. - The rule engine respects the override. Every ingest pass that would otherwise consider this row checks the
assigned_byflag first and skips re-evaluation. - You can clear it. The dropdown has a Reset to rule entry; picking that clears
portfolio_idback to the rule-engine’s decision. - Bulk overrides are available via API. See
/api/portfolios/{id}/assign-recordsin the API reference. The endpoint accepts an array of record ids and re-stamps them all to the target portfolio withassigned_by='manual'.
Validation profiles
A validation profile is a named subset of the 56 system Metro2 validation rules, optionally plus custom rules (Pro+ tier). Each portfolio is attached to exactly one profile; when files are generated for that portfolio, the validator runs only the rules enabled by the profile.
Why per-portfolio? Different programs have legitimately different validity definitions. A BNPL portfolio doesn’t need the credit-limit-required rule (Pay-in-4 has no revolving limit). A mortgage portfolio needs the K3-segment-present rule that doesn’t apply to credit cards. A subprime portfolio may require the originator metadata field that other programs don’t carry.
Shipped system profiles
- Default — the full 56 rules. This is what every backfilled customer’s default portfolio uses, so existing behaviour is unchanged.
- BNPL — the BNPL-applicable subset, with credit-limit, account-status-derivation, and a couple of payment-history rules turned off; adds a
require_business_industry_codeassertion. - Subprime — tightens consumer-information rules and enables Clarity-specific extensions.
- Mortgage — requires K3-segment presence and tightens portfolio-type / account-type pairing.
Custom profiles (Pro+)
Pro+ tier customers can create custom profiles. The rules:
- A custom profile must derive from a system base (Default, BNPL, Subprime, or Mortgage). You cannot start from an empty profile — the system guarantees you always inherit a sane floor of rules.
- Customization is additive in either direction: you can disable individual rules from the base (by id) or enable Pro+ custom rules from your custom-rule library.
- Profiles are scoped to the company — only your company can see and use them. System profiles (NULL
company_id) are visible to everyone.
Manage profiles at /dashboard/validation-profiles (linked from any portfolio’s validation-profile dropdown via Manage profiles).
How the profile is applied
When the generate-file pipeline runs, it loads the portfolio’s profile, computes the active rule set (base rules minus disabled ids, plus custom rules), and passes that set to the validator. The result is written to metro2_validation_results with the portfolio_id stamped on the row, so audit reports can group findings by portfolio.
BNPL specialty-file routing
BNPL Pay-in-4 is the canonical use case that motivated multi-portfolio routing, so it gets first-class support. The BNPL Pay-in-4 template preloads almost everything you need:
- Portfolio flagged
is_specialty_file=truewithbusiness_industry_code="BN". - Two bureau routes:
equifax_bnpl+transunion_bnpl, both inmetro2_bnplformat. Noexperian/equifax/transunioncore routes. - A routing rule named “Account type 07 → BNPL” that fires on
account_type == "07". - The BNPL validation profile, which lifts rules that don’t apply to Pay-in-4 (credit limit required, etc.) and adds a hard check that
BICis present.
The BIC preflight
When a portfolio has is_specialty_file=true, the generate-file pipeline runs an extra preflight check before it emits anything. The check confirms:
- The portfolio has a non-empty
business_industry_code(e.g.BN). - Every bureau route on the portfolio uses a specialty file format (
metro2_bnplormetro2_subprime_ext), not vanillametro2. - No record in the slice carries an
account_typethat’s incompatible with the specialty file (the BNPL file format will reject records that don’t belong).
If any of these fail, generation aborts with a clear error and nothing is sent. This is the guard against “whoops we put a BNPL record in the core file” — the single failure mode this whole system was built to prevent. Fix the failure (usually by setting BIC on the portfolio) and re-run.
Pairing BNPL with a core program
Most BNPL operators also report a non-BNPL product (installment loans, partner card, etc.) under the same Metro2 company. The typical setup:
- Default portfolio (created by backfill, kept untouched) handles the legacy installment book. Routes:
equifax+experian+transunioninmetro2format. - New BNPL Pay-in-4 portfolio created from the template. Routes:
equifax_bnpl+transunion_bnplinmetro2_bnplformat. - The BNPL portfolio carries its routing rule:
account_type == "07". - All other records fall through to the default portfolio. No extra rule needed.
The result: importing a mixed batch sends installment records into the standard core files and Pay-in-4 records into the BNPL partitions, with zero manual sorting.
Per-portfolio file generation
File generation has two entry points after this change:
1. The legacy company-wide endpoint (still works)
POST /api/metro2-records/generate-file. This is the original endpoint and the safety net. It walks the company’s records, generates one file per bureau using the company-level settings, and uploads to storage. We kept it intact so that single-portfolio customers and any external automation built against the old API continue to work.
If a customer has a single Default portfolio (the post-backfill state for every existing customer), the legacy endpoint produces a byte-identical file to the new portfolio-aware path. This is the cycle-1 zero-change guarantee.
2. The portfolio-aware endpoint (new)
POST /api/portfolios/{id}/generate-file. This is the entry point for everything multi-portfolio. It:
- Loads the portfolio and its bureau routes.
- Loads the records stamped with this portfolio’s id.
- Normalises money fields (cents → dollars) the same way the legacy route does.
- Runs the specialty-file preflight if the portfolio is flagged.
- For each enabled bureau route, emits one file in the route’s format, runs the portfolio’s validation profile against it, uploads to storage, and records a row in
metro2_transmissionswith theportfolio_idstamped.
The response is JSON with one entry per route, listing the storage path, byte count, record count, and validation summary.
How the scheduled cron picks which path
The cron worker run-scheduled-submissions wakes up on its cadence and loads every submission schedule that’s due. For each schedule:
- If
schedule.portfolio_idis set, the worker takes the portfolio-aware branch: it calls the per-portfolio generator and emits files for that portfolio’s routes only. - If
schedule.portfolio_idis NULL, the worker falls back to the legacy company-wide branch and behaves exactly as it did before this change.
This is how the “nothing changes unless you opt in” promise is kept at the cron level: old schedules don’t carry aportfolio_id, so they keep running on the old code path. When you create a schedule from the new portfolio UI, the UI sets portfolio_id automatically and the new path takes over.
One-off generation from the UI
From a portfolio detail page click Generate now. This calls /api/portfolios/{id}/generate-file and shows the per-route result inline. Useful for spot-checking a configuration before letting the cron pick it up.
Audit and reconciliation per portfolio
Every record, transmission, and validation result carries a portfolio_id. That makes per-portfolio audit a SQL group-by, and the UI surfaces it on the Audit tab of each portfolio at /dashboard/portfolios/{id}.
The Audit tab shows, for the last N cycles:
- Records in scope — how many records were stamped to this portfolio at the time of generation.
- Files generated — one row per bureau route with byte count, record count, validation summary, and storage link.
- Reconciliation status — whether the bureau returned an ACK / NACK and which records (if any) the bureau rejected on intake. The detail view drills into bureau-specific error codes via the analytics suite.
- Routing decisions — a sampled log of records that were stamped to this portfolio in the cycle, broken down by
assigned_by(rule / manual / default). Useful for spot-checking that the rule engine did what you expected.
For deeper analysis, follow the link on the Audit tab into the Analytics Suite. The analytics suite is portfolio-aware: every dashboard accepts a portfolio filter so you can compare DQ trends, dispute rates, and submission-success rates portfolio-by-portfolio.
API reference
Every UI action calls one of these endpoints. They’re also the right surface for any automation you build — the UI is a thin client. All endpoints accept Authorization: Bearer <API_KEY> or session cookies, and return JSON.
Portfolios
| Method | Path | What it does |
|---|---|---|
GET | /api/portfolios | List portfolios for the authenticated company. |
POST | /api/portfolios | Create a new portfolio. Body matches PortfolioInput. |
GET | /api/portfolios/{id} | Read a single portfolio with its bureau routes and rules. |
PATCH | /api/portfolios/{id} | Partial update. Body matches PortfolioUpdate. |
DELETE | /api/portfolios/{id} | Delete a portfolio. Refuses if records are still stamped to it; reassign them first via /assign-records. |
Bureau routes
| Method | Path | What it does |
|---|---|---|
GET | /api/portfolios/{id}/bureau-routes | List routes for the portfolio. |
POST | /api/portfolios/{id}/bureau-routes | Add a route. Body matches BureauRouteInput. |
PATCH | /api/portfolios/{id}/bureau-routes/{routeId} | Partial update of a single route. |
DELETE | /api/portfolios/{id}/bureau-routes/{routeId} | Remove a route. |
Routing rules
| Method | Path | What it does |
|---|---|---|
GET | /api/portfolios/{id}/routing-rules | List rules for the portfolio. |
POST | /api/portfolios/{id}/routing-rules | Create a rule. Body matches RoutingRuleInput (name, priority, conditions, portfolio_id). |
PATCH | /api/portfolios/{id}/routing-rules/{ruleId} | Partial update. |
DELETE | /api/portfolios/{id}/routing-rules/{ruleId} | Delete the rule. |
POST | /api/portfolios/{id}/routing-rules/preview | Dry-run a proposed rule (or rule set) against the company’s records and return a distribution summary: how many records each portfolio would receive, default fallback count, unrouted count. |
Record assignment + generation + audit
| Method | Path | What it does |
|---|---|---|
POST | /api/portfolios/{id}/assign-records | Bulk stamp a set of record ids to this portfolio with assigned_by='manual'. Body: { record_ids: string[] }. |
POST | /api/portfolios/{id}/generate-file | Generate one file per enabled bureau route on this portfolio. Respects validation profile and specialty-file preflight. Returns per-route storage paths and validation summaries. |
GET | /api/portfolios/{id}/audit | Per-portfolio audit history: cycles, files, transmissions, validation results, and routing-decision samples. |
Validation profiles
| Method | Path | What it does |
|---|---|---|
GET | /api/validation-profiles | List profiles visible to the authenticated company — every system preset plus any custom profiles you’ve authored. |
POST | /api/validation-profiles | Create a custom profile. Must specify a system base; the body lets you list disabled rule ids and any extra custom rule ids. |
GET | /api/validation-profiles/{id} | Read a single profile. |
PATCH | /api/validation-profiles/{id} | Update a custom profile. System profiles are immutable. |
DELETE | /api/validation-profiles/{id} | Delete a custom profile. Refuses if any portfolio currently references it. |
Example: create a BNPL portfolio end-to-end
# 1. Create the portfolio
curl -X POST https://metro2.switchlabs.dev/api/portfolios \
-H "Authorization: Bearer $METRO2_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "BNPL Pay-in-4",
"slug": "bnpl-pay-in-4",
"default_portfolio_type": "I",
"default_account_type": "07",
"metro2_jurisdiction": "US",
"is_specialty_file": true,
"business_industry_code": "BN"
}'
# -> { "id": "00000000-0000-0000-0000-000000000abc", ... }
# 2. Add the two BNPL bureau routes
curl -X POST https://metro2.switchlabs.dev/api/portfolios/00000000-.../bureau-routes \
-H "Authorization: Bearer $METRO2_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "bureau": "equifax_bnpl", "file_format": "metro2_bnpl", "enabled": true }'
curl -X POST https://metro2.switchlabs.dev/api/portfolios/00000000-.../bureau-routes \
-H "Authorization: Bearer $METRO2_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "bureau": "transunion_bnpl","file_format": "metro2_bnpl", "enabled": true }'
# 3. Add the routing rule
curl -X POST https://metro2.switchlabs.dev/api/portfolios/00000000-.../routing-rules \
-H "Authorization: Bearer $METRO2_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Account type 07 -> BNPL",
"priority": 100,
"portfolio_id": "00000000-0000-0000-0000-000000000abc",
"conditions": { "all": [
{ "field": "account_type", "op": "eq", "value": "07" }
]}
}'
# 4. Preview which records the rule will capture
curl -X POST https://metro2.switchlabs.dev/api/portfolios/00000000-.../routing-rules/preview \
-H "Authorization: Bearer $METRO2_API_KEY"
# -> { "total": 12483, "matched_by_portfolio": { "00000000-...": 312 }, ... }
# 5. Generate a one-off file for the new portfolio
curl -X POST https://metro2.switchlabs.dev/api/portfolios/00000000-.../generate-file \
-H "Authorization: Bearer $METRO2_API_KEY"Common questions
Will this break my current reporting?
No. The backfill created exactly one Default portfolio per existing company, mirrored that company’s existing bureau toggles into bureau routes, and stamped every existing record / schedule / transmission / validation result with that portfolio’s id. The file your company generated for the cycle after rollout is byte-identical to the file it would have generated before rollout. 147 of 147 companies were migrated, 7,501 records stamped, zero unrouted — the cycle-1 zero-change contract was held.
Can I have a portfolio without any bureau routes?
Yes — the schema allows it — but the portfolio will not generate any files until you add at least one enabled route. The UI shows a yellow banner on the portfolio detail page when the route set is empty so you don’t silently leave a program dark.
What happens to an account that matches no rule?
It falls through to the company’s default portfolio (the one flagged is_default=true, automatically created by the backfill for every company) and is stamped with portfolio_assigned_by='default'. Because the backfill guarantees every company has a default, no record is ever unrouted in normal operation.
Can I rename “Portfolio” to “Program” in my account?
No. Per the design decisions in DECISIONS.md, the terminology is fixed at Portfolio. We considered making it configurable and decided the cost of inconsistent docs / support conversations / API field names would outweigh the benefit. If “Program” or “Tradeline pool” is what your team says internally, treat Portfolio as the technical synonym.
How is a portfolio different from a Metro2 company / subscriber?
A company is a tenant: one billing account, one set of users, one SFTP identity, one subscriber code by default. A portfolio is a slice of that company’s accounts with its own reporting policy. If you would otherwise have spun up two Metro2 companies to keep two programs separate, you probably want two portfolios instead.
Can a single record be in more than one portfolio?
No. Each record holds a single portfolio_id. If you need a record reported in multiple file formats (say, metro2 and metro2_bnpl), express that as multiple bureau routes on the same portfolio — routes are how a single record fans out to multiple destinations.
Does the rule engine run every cycle?
The rule engine runs on ingest (when records are imported or updated) and any time you explicitly trigger a re-assignment from the UI or via /assign-records. It does not re-run on every cycle by default — that would constantly fight manual overrides and would generate unhelpful churn. If you change a rule and want existing records to be re-evaluated, use the Re-evaluate existing records button on the Rules tab.
Can I delete the Default portfolio?
No. Every company has exactly one portfolio flagged is_default=true, and you can’t delete or un-flag it. You can rename it, change its routes, change its validation profile — you just can’t leave the company without a fallback target for unmatched records.
Troubleshooting
A record is stuck in the wrong portfolio
Open the record detail page and look at the Portfolio assignment card. It tells you:
- The current
portfolio_idand portfolio name. - The
portfolio_assigned_byflag (rule,manual, ordefault). - If
rule, the name and id of the rule that matched.
Walk down this list:
- If the flag is
manual, a human pinned this record. The rule engine will never touch it. Click Reset to rule on the same card to clear the override and let the engine decide on the next ingest pass. - If the flag is
ruleand the wrong rule won, look at the rule priorities on the source portfolio. Rules run ascending; a lower-priority rule that shouldn’t have matched will beat a higher-priority one. Either raise the priority of the rule you want to win, or tighten the conditions on the rule that matched too eagerly. - If the flag is
default, no rule matched and the record fell through to the company default. Open the rule editor on the portfolio you wanted, hit Preview against this record, and tweak conditions until the preview confirms the record matches.
Once the rule is right, use the Re-evaluate existing records button (or hit /api/portfolios/{id}/assign-records) to re-stamp the affected rows.
Preflight error on a BNPL portfolio
The most common failure is “business_industry_code missing on specialty-file portfolio.” Fix: open the portfolio, edit identity, set BIC to BN, save, re-run.
The second-most-common is “route uses standard metro2 file format on specialty portfolio.” Fix: open the offending route, change file format to metro2_bnpl (or metro2_subprime_ext for Clarity).
The third is “record carries incompatible account_type for specialty file.” This means a non-Pay-in-4 record (say a credit card with account_type=R) was stamped to the BNPL portfolio. Re-check your routing rule conditions and either tighten the rule or manually re-assign the stray record.
Unrouted records on the next cycle
The default portfolio is your unrouted-record safety net, so seeing a true unrouted record (where portfolio_id IS NULL) usually means the legacy companies.report_to_* columns are still driving a fallback path for some piece of code that wasn’t portfolio-aware. Check:
- That the company has a default portfolio (it should — the backfill created one for every company; missing default is an anomaly worth reporting to support).
- That the records in question were imported after portfolios shipped. If they were imported through a code path that bypasses the ingest hook (rare, but possible for direct DB imports from old scripts), you can stamp them with the manual assignment API.
- That you’re looking at the live company and not a development copy. Some staging environments seed companies without a default portfolio for test isolation.
The cron generated a file but it’s missing records
Two likely causes:
- The cron took the portfolio-aware branch (the schedule has a
portfolio_id) and only emitted records stamped to that portfolio. Records you expected are likely stamped to a different portfolio. Check the audit tab on the other portfolio. - The cron took the legacy branch (the schedule has no
portfolio_id) and used the company-wide generator. This walks all records regardless of portfolio — you should see your expected records here. If you don’t, the issue is upstream (validation rejection, import gap), not portfolios.
The rule preview count is zero but I expected matches
Almost always one of:
- Wrong field name. The rule DSL uses snake_case (
account_type, notaccountType). For metadata it’smetadata.<key>(one level deep). - Wrong value type. Account types are two-character codes (
"07", not7). State codes are USPS two-letter ("CA", not"California"). Money is in cents. - Case mismatch on a strict regex.
eqis case-insensitive butregexis not by default; include the(?i)flag in your pattern or useeq.
Related guides
- BNPL Translation — how Pay-in-4 records are translated into the
metro2_bnplfile format. Pairs with the BNPL specialty-file routing covered above. - Analytics & Reporting Suite — portfolio-aware DQ dashboards, audit PDFs, and bureau reconciliation views. Every analytics view accepts a portfolio filter.
- Industry Templates — the per-industry validation profile presets (BNPL, Subprime, Mortgage, Auto) that ship with portfolios. Custom profiles inherit from these system bases.
Still stuck? Contact support or browse the Help Center.