BNPL Translation Layer
Translate Pay-in-4, biweekly, and other sub-monthly installment products into compliant Metro 2 records — with the right PHP cells, the right DOFD, the right roll-up granularity, and the right bureau channel.
1. The BNPL problem
Metro 2 was designed in the 1990s for products with monthly billing cycles: credit cards, auto loans, mortgages, traditional installment loans. Every field in the spec assumes a monthly cadence — the Payment History Profile (PHP) is a 24-cell grid where each cell is one calendar month, the Date of Account Information is monthly, the Date of First Delinquency (DOFD) is implicitly anchored to monthly billing buckets, and the balance / past-due dollar fields refresh once per cycle.
Pay-in-4 (P4) and other BNPL products break every one of these assumptions:
- 4 biweekly payments over 6 weeks. A P4 loan originated on day 0 has installments on days 0, 14, 28, and 42. The entire loan completes inside ~1.4 monthly cycles. There is no "monthly statement" to map cleanly onto a PHP cell.
- Each retailer purchase is potentially a new tradeline. A consumer who uses Affirm Pay-in-4 once a week for groceries generates ~52 separate loans a year. Furnished naively, that shows up on the credit file as 52 tradelines opening and closing inside 12 months — score-destroying noise.
- Bureaus disagree on how to ingest BNPL.
Where each bureau stands (as of May 2026)
| Bureau | Channel | Format notes | Score impact |
|---|---|---|---|
| Equifax | Standard Metro 2 file | Tagged via Business Industry Code (BIC); furnisher picks Installment (I/18) or Revolving (R/10) | Visible to FICO Score 10 BNPL |
| TransUnion | Metro 2 with BNPL tag flag | Tag flag in a reserved position; specialty filter determines who sees it | Suppressed from core scores by default |
| Experian | Dedicated Buy Now, Pay Later Bureau | Separate specialty file format (not Metro 2 fixed-width); ingest spec is gated and changes quarterly | Sequestered from FICO 8 / VantageScore 3; visible to opt-in lenders |
And the political backdrop keeps moving:
- Klarna and Afterpay still publicly refuse to furnish in 2026, despite a Senate Banking letter pressuring them in November 2025.
- The CFPB withdrew its 2024 BNPL interpretive rule in May 2025, signaling it will not impose a Reg Z credit-card-style framework on P4. FCRA accuracy obligations under §623(a) still apply.
- FICO Score 10 BNPL / 10 T BNPL launched Fall 2025 with explicit Pay-in-4 aggregation logic, signaling the scoring side has finally accepted BNPL as first-class data.
The BNPL Translation Layer is the engine that absorbs all of this volatility. You send us your native BNPL data — loans, schedules, payment events — and we emit compliant Metro 2 records on the right cadence to the right bureau in the right format.
2. What this layer does
The translation layer covers four hard problems no Metro 2 SaaS solves out of the box:
- Biweekly → monthly PHP synthesis. Given a biweekly schedule plus payment events, we compute the 24-cell monthly Payment History Profile string — with carry-forward delinquency tracking, charge-off (
L) and paid-off (D) overlays, and "no history yet" (B) cells filled correctly. - Configurable per-loan vs aggregate roll-up. You decide whether 50 micro-loans for one consumer become 50 tradelines, 50 monthly buckets per merchant, one aggregate per consumer, or anything in between. The roll-up is set per bureau; you can run Equifax aggregated and Experian per-loan if that is what your spec calls for.
- DOFD calculation under FCRA rules. Date of First Delinquency anchors the 7-year obsolescence clock — get it wrong and you create FCRA §623(b) exposure. We default to the monthly-bucket reading (
due_at + 30 days), which is what every other standard-channel contributor uses, and we block the riskier strict reading on the standard Metro 2 channel both in code and in the database. - Bureau routing via multi-portfolio. Translated records land in
metro2_recordstagged with aportfolio_idthat points to your BNPL portfolio. Your existing bureau routes on that portfolio decide which bureaus get the file and in which format. See the Multi-Portfolio Routing guide for the full story.
3. Roll-up policies
A roll-up policy tells the translator how to group your bnpl_loans rows into Metro 2 tradelines. One policy per bureau. You pick a granularity and a target format, and the translator does the rest.
The four granularities
| Granularity | One tradeline per… | Typical use | Default for |
|---|---|---|---|
per_loan | Loan | Affirm / Experian model. Each purchase reports as its own short-duration tradeline. Highest fidelity, highest tradeline count. | Default — applied if no policy is set |
per_consumer_merchant | (consumer, merchant) pair, ever-open | Looks more like a store charge account. All Apple Pay-in-4 purchases on Walmart roll into one Walmart-branded line. | — |
per_consumer_merchant_month | (consumer, merchant, calendar-month-of-origination) | Monthly statement model. A new tradeline opens each month per merchant the consumer used; previous months close out. | — |
per_consumer | Consumer | Single aggregate "BNPL relationship" per consumer per bureau. Reduces tradeline noise to the maximum extent. Subject to the coherence check below. | — |
The coherence fallback
per_consumer is the only granularity that pools loans across products. If two loans for the same consumer have different currency_code or different jurisdiction (US vs CA), they cannot be combined into one tradeline — the dollar fields would be incoherent and the bureau spec would reject the file. When this happens the translator silently falls back to per_loan for the affected consumer and records the reason in derivation_log:
{
"rule": "rollup_coherence_failed_fallback_per_loan",
"input": {
"consumer": "cust_8a32f01b",
"currencies": ["USD", "CAD"],
"jurisdictions": ["US", "CA"]
}
}Other consumers in the same translation run are unaffected — only the incoherent ones drop down to per_loan.
Loans without a consumer key
Loans with no consumer_id AND no external_consumer_id are skipped entirely (logged as rollup_skipped_loans_without_consumer). You can furnish them later once you attach a consumer.
4. DOFD method
Date of First Delinquency (DOFD) starts the FCRA 7-year obsolescence clock. Per §605(c)(1), once 84 months pass from DOFD on an account that went delinquent and stayed that way, the account must drop off the consumer's credit file. Two competing readings exist for sub-monthly products:
| Method | DOFD formula | Allowed on… | Default? |
|---|---|---|---|
monthly_bucket | oldest_unresolved_due_at + 30 days | Every target format | Yes — the only safe choice for standard_metro2 |
strict | oldest_unresolved_due_at + 1 day | experian_bnpl_specialty / transunion_tagged only — opt-in, specialty channels only | No |
monthly_bucket is the default because Metro 2 PHP cells are monthly buckets: a cell becomes "1" (30 days late) the day the account first crosses the 30-day threshold. Every other furnisher contributing to a standard bureau file follows this convention. A furnisher who reports strict DOFDs is out of step with peers and is telegraphing a delinquency 29 days before everyone else would, which is the textbook fact pattern for an FCRA §623(b) accuracy complaint.
How strict is blocked on standard channels
The block lives in three places, so it cannot be bypassed:
- API layer.
POST /api/v1/bnpl/rollup-policiesreturns 400 withSTRICT_DOFD_BLOCKED_ON_STANDARDif you try to combinetarget_format="standard_metro2"withdofd_method="strict"in the aggregation strategy. - Database CHECK constraint.
bnpl_rollup_policieshas a CHECK that rejects the same combination at the row level — even if you reach in via direct SQL. - Type enum. The TypeScript type
BnplValidationErrorand theBNPL_DOFD_METHODSenum carry the documented error so SDKs surface the failure mode in their type system.
To use strict, route the affected loans to a specialty channel via target_format = experian_bnpl_specialty or transunion_tagged on the policy.
How DOFD resets
DOFD always points at the oldest unresolved missed installment. Concretely:
- Consumer misses installment #2 → DOFD anchors to
due_at(#2) + 30. - Consumer catches up on #2 → DOFD resets to null (account is current again).
- Consumer later misses #4 → DOFD anchors to
due_at(#4) + 30(NOT back to #2). - Consumer remains delinquent through #2, #3, #4 → DOFD stays at
due_at(#2) + 30the whole time.
A reversed, waived, or fully-paid installment counts as resolved. A partial payment counts as unresolved.
5. Biweekly → monthly PHP synthesis
The PHP grid is a 24-character string. Position 0 is the most recent month (the month before the file's as-of date); position 23 is 24 months ago. Each character is one of:
| Code | Meaning |
|---|---|
0 | Paid as agreed (current) |
1 | 30–59 days past due |
2 | 60–89 days past due |
3 | 90–119 days past due |
4 | 120–149 days past due |
5 | 150–179 days past due |
6 | 180+ days past due |
B | No history this month (before opening, after closing) |
D | Account paid in full / closed (overlay on closing month) |
L | Charged off (fills every cell from charge-off month forward) |
How the synthesis works
For each of the 24 positions, the translator looks at the calendar month that position represents and asks: what is the worst-state installment whose due date is on or before the end of that month and which is still unresolved as of the month's end?
- All due-by-end-of-month installments paid → cell is
0. - Some installment unresolved → cell is the bucket of its days past due as of cycle end (
1through6). - No installments due in or before that month → cell is
B(no history yet). - Month is strictly before
opened_at→ cell isB.
Then the terminal overlays apply: L fills every cell from the charge-off month forward; D overlays just the closing month for paid_off and refunded loans.
Worked example: 4 biweekly payments in 2 monthly buckets
A Pay-in-4 loan originated March 18, 2026 with a clean payment history would have installments on:
#1 2026-03-18 paid
#2 2026-04-01 paid
#3 2026-04-15 paid
#4 2026-04-29 paidGenerated for an as-of date of 2026-05-15, the PHP grid would be:
Position: 0 1 2 3 4 ... 23
Month: Apr Mar Feb Jan Dec ... May'24
Cell: 0 0 B B B ... B
^^^^^^^^^^^^^^^^^
all months before loan openingThe four biweekly events compressed into two monthly buckets (March and April), both clean, so both cells are 0. Everything before March 2026 is B.
Worked example: carry-forward delinquency
Same loan but the consumer misses installment #2 and never catches up:
#1 2026-03-18 paid
#2 2026-04-01 missed <-- DOFD = 2026-05-01 (monthly_bucket)
#3 2026-04-15 missed
#4 2026-04-29 missedAs of 2026-07-15, installment #2 is 105 days past due. The April cell shows the worst bucket for any installment due-by-April:
Position: 0 1 2 3 ... 23
Month: Jun May Apr Mar ... Jul'24
Cell: 3 2 3 0 ... B
^ ^ ^ ^
| | | first installment (paid) — clean
| | April: #2 is 89 dpd, #3 is 75 dpd, #4 is 61 dpd → worst = 2 (60–89)
| May cycle end: #2 is 60 dpd → 2; #3 is 46 dpd, #4 is 32 dpd → still 2
Jun cycle end: #2 is 90 dpd → 3The translator's carry-forward logic tracks each unresolved installment across cycles, growing its days-past-due month over month — exactly how a still-delinquent account is reported in classic Metro 2.
Worked example: charge-off overlay
If the loan is charged off on 2026-08-10:
Lifecycle: charged_off
charged_off_at: 2026-08-10
As of 2026-10-15:
Position: 0 1 2 3 4 ... 23
Month: Sep Aug Jul Jun May ... Nov'24
Cell: L L 3 3 2 ... B
^^^^^
Charge-off fills the August month forward.Worked example: paid-off overlay
If the consumer pays off the loan cleanly:
Lifecycle: paid_off
closed_at: 2026-04-29
As of 2026-06-15:
Position: 0 1 2 3 ... 23
Month: May Apr Mar Feb ... Jun'24
Cell: B D 0 B ... B
^ ^ ^
| | March cycle: #1 paid → 0
| April cycle: closed → D overlay
Months after close → B (no further activity)Length is always 24
The output is a 24-character string, right-padded with B if the loan history is shorter (and most P4 loans are shorter — only 6 weeks of history exists for a P4 loan in isolation, so positions 2 through 23 are nearly always B unless the loan was rolled up with siblings).
6. Sending loans
The loan endpoint accepts one loan plus its full installment schedule in a single call. Loans are upserted on the unique key (company_id, external_loan_id), so re-sending the same payload is idempotent.
POST /api/v1/bnpl/loans — create or upsert
curl -X POST https://metro2.switchlabs.dev/api/v1/bnpl/loans \
-H "Authorization: Bearer ${METRO2_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"external_loan_id": "affirm-loan-9183124",
"external_consumer_id": "cust_8a32f01b",
"external_merchant_id": "merchant_walmart_us",
"portfolio_id": "8d3b9c20-0a4e-4d2d-9d8c-a812e6b8c0a3",
"loan_type": "pay_in_4",
"originated_at": "2026-03-18T14:22:08Z",
"principal_cents": 14000,
"apr_bps": 0,
"schedule_cadence": "biweekly",
"num_scheduled_installments": 4,
"currency_code": "USD",
"jurisdiction": "US",
"schedule": [
{ "installment_number": 1, "due_at": "2026-03-18", "scheduled_amount_cents": 3500 },
{ "installment_number": 2, "due_at": "2026-04-01", "scheduled_amount_cents": 3500 },
{ "installment_number": 3, "due_at": "2026-04-15", "scheduled_amount_cents": 3500 },
{ "installment_number": 4, "due_at": "2026-04-29", "scheduled_amount_cents": 3500 }
]
}'Key things to know:
- All money is in cents.
principal_centsandscheduled_amount_centsare integers — never decimals.$140.00becomes14000. external_loan_idis your dedupe key. POSTing the same value twice updates the existing row.- Identify the consumer one of two ways. Either
consumer_id(Metro2 UUID for an existing consumer in your PII vault) orexternal_consumer_id(your stable customer ID). The translator accepts either. - Merchant is optional but required if you plan to use
per_consumer_merchant*roll-up. Passexternal_merchant_idand we'll upsert a merchant row for you. - Jurisdiction defaults to
US; set toCAfor Affirm Canada / Klarna Canada loans.
GET /api/v1/bnpl/loans — list loans
curl -G https://metro2.switchlabs.dev/api/v1/bnpl/loans \
-H "Authorization: Bearer ${METRO2_API_KEY}" \
--data-urlencode "external_consumer_id=cust_8a32f01b" \
--data-urlencode "lifecycle_state=active" \
--data-urlencode "limit=50"Filters available: external_consumer_id, merchant_id, lifecycle_state, jurisdiction, originated_after, originated_before.
GET /api/v1/bnpl/loans/{id} — single loan with installments
curl https://metro2.switchlabs.dev/api/v1/bnpl/loans/8d3b9c20-0a4e-4d2d-9d8c-a812e6b8c0a3 \
-H "Authorization: Bearer ${METRO2_API_KEY}"Returns the loan plus its installments, current derived_dofd, the most recent bnpl_translations rows for this loan, and a preview of the derivation log.
7. Sending events
Once a loan exists, you record payment events against individual installments. Events are idempotent on (loan_id, type, installment_number, occurred_at) and are the right primitive for everything that happens after origination — installment payments, missed payments, partial payments, refunds, waivers, and reversals.
POST /api/v1/bnpl/loans/{id}/events
curl -X POST https://metro2.switchlabs.dev/api/v1/bnpl/loans/8d3b9c20.../events \
-H "Authorization: Bearer ${METRO2_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"type": "paid",
"installment_number": 2,
"amount_cents": 3500,
"occurred_at": "2026-04-01T09:14:00Z"
}'Event types
| type | What it means | Installment state after |
|---|---|---|
paid | Installment paid in full | paid if occurred_at ≤ due_at; paid_late otherwise |
missed | Installment confirmed missed (no funds collected) | missed |
partial | Partial collection (amount_cents required, less than scheduled) | partial |
waived | Furnisher waived the installment (counts as resolved for DOFD) | waived |
reversed | Refund / chargeback / dispute resolution that erased the installment | reversed |
Special case: posting type="missed" events for consecutive installments will eventually trip the charge-off policy on your loan and the DOFD watchdog will set derived_dofd on the loan automatically. You can also post a synthetic charge-off via the loan's metadata if your collections system records it explicitly — see the API reference.
8. Bulk import
For backfills and large nightly drops, use the NDJSON streaming endpoint. One JSON object per line; each object is the same shape as POST /api/v1/bnpl/loans. The server streams the body, parses line by line, and returns a per-line result.
POST /api/v1/bnpl/loans/bulk
curl -X POST https://metro2.switchlabs.dev/api/v1/bnpl/loans/bulk \
-H "Authorization: Bearer ${METRO2_API_KEY}" \
-H "Content-Type: application/x-ndjson" \
--data-binary @bnpl-loans-2026-05.ndjsonExample NDJSON payload (one loan per line):
{"external_loan_id":"l-1","external_consumer_id":"c-100","loan_type":"pay_in_4","originated_at":"2026-03-01T00:00:00Z","principal_cents":12000,"schedule_cadence":"biweekly","num_scheduled_installments":4,"schedule":[{"installment_number":1,"due_at":"2026-03-01","scheduled_amount_cents":3000},{"installment_number":2,"due_at":"2026-03-15","scheduled_amount_cents":3000},{"installment_number":3,"due_at":"2026-03-29","scheduled_amount_cents":3000},{"installment_number":4,"due_at":"2026-04-12","scheduled_amount_cents":3000}]}
{"external_loan_id":"l-2","external_consumer_id":"c-101","loan_type":"pay_in_4","originated_at":"2026-03-02T00:00:00Z","principal_cents":8000,"schedule_cadence":"biweekly","num_scheduled_installments":4,"schedule":[{"installment_number":1,"due_at":"2026-03-02","scheduled_amount_cents":2000},{"installment_number":2,"due_at":"2026-03-16","scheduled_amount_cents":2000},{"installment_number":3,"due_at":"2026-03-30","scheduled_amount_cents":2000},{"installment_number":4,"due_at":"2026-04-13","scheduled_amount_cents":2000}]}The response is also NDJSON, one result per input line:
{"line":1,"external_loan_id":"l-1","status":"ok","id":"8d3b9c20-..."}
{"line":2,"external_loan_id":"l-2","status":"ok","id":"6e2a4b18-..."}A failed line emits {"line":N,"status":"error","error":"...","code":"..."} and processing continues. Total throughput is roughly 200 loans/second per company on the default plan.
9. Managing roll-up policies
A roll-up policy is the combination of bureau, target format, granularity, and an open aggregation strategy JSON object that fills in the rest of the Metro 2 field defaults (portfolio type, account type, terms frequency, balance formula, etc.).
POST /api/v1/bnpl/rollup-policies
curl -X POST https://metro2.switchlabs.dev/api/v1/bnpl/rollup-policies \
-H "Authorization: Bearer ${METRO2_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"bureau": "equifax",
"granularity": "per_loan",
"target_format": "standard_metro2",
"portfolio_id": "8d3b9c20-0a4e-4d2d-9d8c-a812e6b8c0a3",
"aggregation_strategy": {
"dofd_method": "monthly_bucket",
"portfolio_type": "I",
"account_type": "18",
"terms_frequency": "B",
"credit_limit": "sum_principal_30d",
"balance": "sum_outstanding"
}
}'The translator validates the combination before saving — for example, this request would 400:
{
"error": "STRICT_DOFD_BLOCKED_ON_STANDARD",
"message": "DOFD method 'strict' is not allowed on target_format='standard_metro2' (FCRA exposure). Use 'monthly_bucket' or move to a specialty channel."
}And this one would 400 because portfolio routing is required for the standard channel:
{
"error": "STANDARD_REQUIRES_PORTFOLIO",
"message": "target_format='standard_metro2' requires portfolio_id"
}GET /api/v1/bnpl/rollup-policies
curl https://metro2.switchlabs.dev/api/v1/bnpl/rollup-policies \
-H "Authorization: Bearer ${METRO2_API_KEY}"Returns all policies for your company, grouped by bureau:
{
"policies": [
{
"id": "f1c0...",
"bureau": "equifax",
"granularity": "per_loan",
"target_format": "standard_metro2",
"portfolio_id": "8d3b9c20...",
"active": true,
"effective_from": "2026-05-01",
"effective_until": null
},
{
"id": "a4e7...",
"bureau": "experian",
"granularity": "per_loan",
"target_format": "experian_bnpl_specialty",
"portfolio_id": null,
"active": true,
"effective_from": "2026-05-01",
"effective_until": null
}
]
}Versioning
A policy is unique on (company_id, bureau, effective_from). To change policy, POST a new policy with a later effective_from and the translator will use the policy in effect at the translation's as-of date. Old policies stay around as the historical record for any disputes that reference past files.
Aggregation strategy fields
| Key | Purpose | Typical value |
|---|---|---|
dofd_method | How DOFD is derived | monthly_bucket |
portfolio_type | CDIA Portfolio Type (field 14) | I (installment) or R (revolving) |
account_type | CDIA Account Type (field 15) | 18 (installment sales contract) |
terms_frequency | Field 28 | B biweekly (the latent hook BNPL needs) or M monthly |
credit_limit | Number, or "sum_principal_30d" for a rolling-30-day sum | "sum_principal_30d" |
balance | Number, or "sum_outstanding" to sum unpaid scheduled amounts | "sum_outstanding" |
The strategy schema is intentionally open ( passthrough in Zod terms) — your SDK is expected to tolerate keys it doesn't recognize. New fields will appear here as bureau specs evolve.
10. Translation pipeline
Translation is the step that turns your raw bnpl_loans + bnpl_installments into rows in metro2_records ready for file generation. You can run it three ways:
1. Manual run
curl -X POST https://metro2.switchlabs.dev/api/v1/bnpl/translate \
-H "Authorization: Bearer ${METRO2_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"as_of": "2026-05-31",
"bureau": "equifax",
"dry_run": false
}'Optional body fields:
as_of— ISO date, defaults to today UTC. Drives DOFD calculations and the PHP grid anchor.bureau— restrict the run to one bureau. Omit to translate all bureaus you have active policies for.dry_run— whentrue, the translator computes the diff (which loans would change, what the PHP cells would be) but does not write tometro2_recordsorbnpl_translations. Great for "what would this policy change do" previews.
Response shape:
{
"as_of": "2026-05-31",
"bureaus": ["equifax"],
"groups_translated": 312,
"loans_processed": 1247,
"loans_skipped": 4,
"metro2_records_written": 312,
"translations_written": 312,
"errors": []
}2. Nightly auto-translate
The bnpl-translate-nightly cron runs at 02:00 UTC daily. It walks bnpl_loans that were updated in the last 24 hours, plus any whose installments crossed a DOFD or PHP-bucket threshold overnight, and recomputes their translations into the current reporting cycle. You do not have to call the manual endpoint for normal operation — the cron keeps your file in sync.
3. Hourly DOFD watchdog
The bnpl-dofd-watchdog cron runs hourly. It looks for any installment whose days past due crossed the DOFD threshold (default 30 days, or 1 day under strict on specialty channels) in the last window and stamps derived_dofd on the parent loan. This means a missed installment cannot quietly sit for a day before its DOFD is recorded — the worst-case lag is one hour.
4. Weekly obsolescence sweep
The bnpl-obsolescence-sweep cron runs weekly. Per FCRA §605(c)(1) and our internal obsolescence-omit-don't-DA rule, BNPL records whose DOFD is more than 83 months ago are marked obsolete and omitted from future translation runs. They are not reported as "Delete Account" (DA), which would re-trigger the bureau's 7-year clock from the deletion date instead of from DOFD. The original bnpl_loans rows are kept (with obsolete=true) for your own audit purposes.
11. Bureau routing
Translated records do not pick their own bureau — they inherit a portfolio_id from the roll-up policy, and your existing bureau routes on that portfolio decide which bureaus the file goes to and in which format. This is the same mechanism described in the Multi-Portfolio Routing guide — the BNPL Translation Layer is just one more producer of portfolio-tagged records.
Recommended portfolio setup
The simplest setup is a dedicated BNPL portfolio per furnisher, with three bureau routes attached:
| Bureau | Format | Required field hints |
|---|---|---|
| Equifax | Standard Metro 2 + BIC (BNPL exchange) | business_industry_code = "BN" injected into the header per Equifax's BNPL contributor doc |
| TransUnion | TransUnion BNPL (tagged Metro 2) | BNPL tag flag in the reserved position |
| Experian | Experian BNPL specialty (Phase 3) | Not Metro 2 fixed-width; uses Experian's proprietary BNPL ingest spec |
| Clarity Services | Subprime specialty (optional) | For subprime-tilted BNPL portfolios |
BIC preflight on specialty files
When the route emits a specialty file (Equifax BNPL exchange, TransUnion BNPL), the preflight validator enforces that the Business Industry Code is present before the file is shipped. A BNPL record without a BIC on a specialty channel is a hard reject — better to fail on our side than have the bureau reject the whole file.
How the linkage is recorded
Each bnpl_translations row carries:
rollup_policy_id— which policy produced this translationmetro2_record_id— whichmetro2_recordsrow it materialized intosource_loan_ids— the array ofbnpl_loansrows that rolled up into this tradelinederivation_log— full audit trail (every rule firing, inputs, outputs) for FCRA §623(b) disputes
Translated metro2_records rows are also tagged with a bnpl_translation_id column so you can join either direction from a Metro 2 record back to the BNPL data that produced it.
12. Regulatory tracker
BNPL standards move quarterly. Bureau specs change, scoring models change, state laws change, and the federal posture changes with administrations. We maintain a live tracker page that records the latest known state of each of these things, along with the source documents and dates.
Visit the BNPL Compliance Tracker for:
- CFPB rule status — the withdrawn 2024 interpretive rule, any replacement proposals, and current enforcement posture.
- FICO Score 10 BNPL — aggregation logic, GA timeline, and which lenders have opted in.
- Bureau positions — current accepted channels, format versions, and any spec changes since the last review.
- Klarna / Afterpay posture — public statements, Senate Banking correspondence, and any forthcoming participation announcements.
- State law tracker — NY DFS, CA DFPI, CT, VA, and other state-licensed lender frameworks that affect BNPL furnishing.
The tracker is updated as new information surfaces — typically weekly during active rule-making windows. Subscribe to email alerts for material changes from your account settings.
13. What's coming (Phase 3+)
Several capabilities are intentionally deferred until upstream dependencies land. We will not ship best-guess implementations of these — the cost of a wrong implementation (a wrongly-furnished delinquency, a bureau rejection on a whole file, an FCRA exposure) is too high.
- Experian BNPL specialty file emitter. Experian ships their intake spec only after a vendor onboarding call. We will support the format once the spec is in hand and we have two furnishers in production using it.
- TransUnion tagged flag in its reserved-position byte — confirmed wire-position pending TransUnion's response to our 2026 spec inquiry.
- State-licensed lender variants. NY DFS BNPL licensure (effective 2025) and CA DFPI both have specific reporting practices that affect required fields. Will add once an early customer ships under one of those licenses.
- Consumer opt-out flow. Not federally required for BNPL but emerging in state laws (especially CA). Deferred until a customer needs it.
- BNPL specialty analytics tiles in the analytics suite — tradeline-count distributions, DOFD heat map, score-impact attribution (where the bureau returns it). Decoupled from translation; will ship when the analytics suite is GA.
14. Common questions
Why is strict DOFD blocked on standard Metro 2?
Every other contributor to a standard bureau file derives DOFD using monthly-bucket semantics. A furnisher who reports astrict DOFD is delivering a delinquency anchor 29 days earlier than the rest of the industry, on the same shared file. That asymmetry is the textbook fact pattern for an FCRA §623(b) accuracy complaint — the consumer says "every other card I have reports me at 30 days; only this one calls me late at day 1." To use strict, route the loans to a specialty channel where the consumer has signed up specifically for sub-monthly reporting.
What if my biweekly schedule has odd cadences (e.g., monthly first installment then biweekly)?
Pick loan_type="split_pay_irregular" and schedule_cadence="irregular". The translator does not require equal spacing between installments — it operates on the literal due_at dates you send. For roll-up, you will likely want per_loan on this loan; the coherence fallback will catch you anyway if you have mixed cadences inside aper_consumer roll-up.
Are translated records visible in metro2_records?
Yes. Translated rows land in metro2_records exactly like records you would have imported directly, with a bnpl_translation_id column pointing back at the bnpl_translations row that produced them. They flow through the standard generate-file pipeline, the standard validator, the standard bureau-routing logic. From a Metro 2 consumer's standpoint, they look like any other record.
What about loans I imported before I set up a roll-up policy?
Until a policy exists for a bureau, those loans sit untranslated in bnpl_loans. They will not appear in any generated file. The moment you POST a policy, the next nightly translate (or a manual POST /api/v1/bnpl/translate run) picks them up and produces the corresponding metro2_records rows.
Can I run two roll-up policies in parallel as an A/B test?
Not directly — only one active policy per bureau at any effective date. To compare, run a dry_run translation against a hypothetical policy and compare the generated PHP grids and tradeline counts to your live policy. Phase 4 will add a first-class A/B mode.
Why was a loan skipped in my translation run?
The translator skips a loan if:
- it has no
consumer_idand noexternal_consumer_id(you need a consumer key to roll up at all) - it is marked
obsolete=true(past the 83-month FCRA threshold) - it has
lifecycle_state="refunded"with no remaining unresolved installments and no DOFD ever recorded — there is nothing to report - no active roll-up policy covers the loan's bureau target
The skip reason is in derivation_log on the run summary returned by POST /api/v1/bnpl/translate.
How does this interact with my existing Metro 2 records?
BNPL translation only writes records into metro2_records that are tagged with a BNPL portfolio. Your existing non-BNPL records are untouched. You can have BNPL and non-BNPL records in the same generated file (they go to different portfolios but the same bureau) or in separate files (one per portfolio) — your bureau-route configuration decides.
15. Troubleshooting
The translator skipped a loan I expected to translate
- Check
GET /api/v1/bnpl/loans/{id}for the loan'sderivation_log. The most common reason for a skip is the coherence fallback — mixed currency or jurisdiction inside aper_consumerroll-up, which silently drops back to per-loan for the offending consumer. - Confirm the loan has a non-null
consumer_idorexternal_consumer_id. - Confirm the loan is not marked
obsolete=true— obsolete loans are filtered out of all future runs. - Confirm there is an active roll-up policy for the bureau you are translating to. No policy → no translation.
DOFD did not update after a missed installment
- Check the loan's installments — DOFD only fires if the oldest unresolved installment's
due_at + thresholdis in the past. - Check the
bnpl-dofd-watchdogcron log. The watchdog runs hourly; if it failed (DB blip, deploy in-flight), it will retry on the next hour. - Confirm the installment state is
missed/partial/scheduled, notwaivedorreversed(which count as resolved and do not fire DOFD). - Confirm the policy's
aggregation_strategy.dofd_methodis set as you expect. The default ismonthly_bucket(30-day threshold); if you intendedstricton a specialty channel, the policy itself must say so.
A translated record went to the wrong bureau
- Open the
portfolio_idon the loan / on the roll-up policy. Translated records get their bureau from the portfolio's bureau-route config, not from the BNPL layer directly. - On that portfolio, list bureau routes (see Multi-Portfolio Routing). Confirm the right bureau is enabled and routed to the right format.
- If you recently changed routes, remember the route applies to future file generations — already-generated files are not retroactively re-routed.
The PHP grid does not match what I expect
- Open the latest
bnpl_translationsrow for the loan and read thederivation_log. Each rule firing is annotated with its inputs and outputs, including the pre-overlay PHP and the post-overlay PHP. - Remember position 0 is the PREVIOUS month, not the current month. A loan that's active right now will show
Bat position 0 if no installments were due last month. - Verify the as-of date — if you ran the translation early in the morning UTC, "today" in your local time zone might still be yesterday in UTC.
A policy POST returned STRICT_DOFD_BLOCKED_ON_STANDARD
You combined target_format="standard_metro2" with dofd_method="strict". This combination is blocked at the API, in the database, and in the type system. Either change dofd_method to "monthly_bucket", or change target_format to a specialty channel (experian_bnpl_specialty / transunion_tagged).
A policy POST returned STANDARD_REQUIRES_PORTFOLIO
You set target_format="standard_metro2" but did not supply a portfolio_id. Standard-channel records must live in a portfolio so bureau routing knows where to send them. Create a BNPL portfolio first, then reference its ID on the policy.
16. Related guides
- Multi-Portfolio Routing — how portfolio-tagged records get sent to specific bureaus in specific formats. Required reading if you are setting up BNPL alongside other product lines.
- Industry Templates — pre-built field defaults for common product types, including a BNPL template that pre-fills
portfolio_type,account_type, andterms_frequencyfor you. - Payment History Grid — the underlying visualization component used to render the 24-cell PHP string in the dashboard. The BNPL view adds a biweekly underlay to the same component.
- BNPL Compliance Tracker — live tracker of CFPB, bureau, and state-law developments affecting BNPL furnishing.
Still stuck? Contact support or browse the Help Center.