Help CenterBNPL Translation
    BNPL18 min read

    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)

    BureauChannelFormat notesScore impact
    EquifaxStandard Metro 2 fileTagged via Business Industry Code (BIC); furnisher picks Installment (I/18) or Revolving (R/10)Visible to FICO Score 10 BNPL
    TransUnionMetro 2 with BNPL tag flagTag flag in a reserved position; specialty filter determines who sees itSuppressed from core scores by default
    ExperianDedicated Buy Now, Pay Later BureauSeparate specialty file format (not Metro 2 fixed-width); ingest spec is gated and changes quarterlySequestered 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_records tagged with a portfolio_id that 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

    GranularityOne tradeline per…Typical useDefault for
    per_loanLoanAffirm / 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-openLooks 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_consumerConsumerSingle 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:

    MethodDOFD formulaAllowed on…Default?
    monthly_bucketoldest_unresolved_due_at + 30 daysEvery target formatYes — the only safe choice for standard_metro2
    strictoldest_unresolved_due_at + 1 dayexperian_bnpl_specialty / transunion_tagged only — opt-in, specialty channels onlyNo

    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:

    1. API layer. POST /api/v1/bnpl/rollup-policies returns 400 with STRICT_DOFD_BLOCKED_ON_STANDARD if you try to combine target_format="standard_metro2" with dofd_method="strict" in the aggregation strategy.
    2. Database CHECK constraint. bnpl_rollup_policies has a CHECK that rejects the same combination at the row level — even if you reach in via direct SQL.
    3. Type enum. The TypeScript type BnplValidationError and theBNPL_DOFD_METHODS enum 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) + 30 the 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:

    CodeMeaning
    0Paid as agreed (current)
    130–59 days past due
    260–89 days past due
    390–119 days past due
    4120–149 days past due
    5150–179 days past due
    6180+ days past due
    BNo history this month (before opening, after closing)
    DAccount paid in full / closed (overlay on closing month)
    LCharged 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 (1 through 6).
    • No installments due in or before that month → cell is B (no history yet).
    • Month is strictly before opened_at → cell is B.

    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  paid

    Generated 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 opening

    The 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  missed

    As 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 → 3

    The 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_cents and scheduled_amount_cents are integers — never decimals. $140.00 becomes 14000.
    • external_loan_id is 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) or external_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. Pass external_merchant_id and we'll upsert a merchant row for you.
    • Jurisdiction defaults to US; set to CA for 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

    typeWhat it meansInstallment state after
    paidInstallment paid in fullpaid if occurred_at ≤ due_at; paid_late otherwise
    missedInstallment confirmed missed (no funds collected)missed
    partialPartial collection (amount_cents required, less than scheduled)partial
    waivedFurnisher waived the installment (counts as resolved for DOFD)waived
    reversedRefund / chargeback / dispute resolution that erased the installmentreversed

    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.ndjson

    Example 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

    KeyPurposeTypical value
    dofd_methodHow DOFD is derivedmonthly_bucket
    portfolio_typeCDIA Portfolio Type (field 14)I (installment) or R (revolving)
    account_typeCDIA Account Type (field 15)18 (installment sales contract)
    terms_frequencyField 28B biweekly (the latent hook BNPL needs) or M monthly
    credit_limitNumber, or "sum_principal_30d" for a rolling-30-day sum"sum_principal_30d"
    balanceNumber, 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 — when true, the translator computes the diff (which loans would change, what the PHP cells would be) but does not write to metro2_records or bnpl_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:

    BureauFormatRequired field hints
    EquifaxStandard Metro 2 + BIC (BNPL exchange)business_industry_code = "BN" injected into the header per Equifax's BNPL contributor doc
    TransUnionTransUnion BNPL (tagged Metro 2)BNPL tag flag in the reserved position
    ExperianExperian BNPL specialty (Phase 3)Not Metro 2 fixed-width; uses Experian's proprietary BNPL ingest spec
    Clarity ServicesSubprime 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 translation
    • metro2_record_id — which metro2_records row it materialized into
    • source_loan_ids — the array of bnpl_loans rows that rolled up into this tradeline
    • derivation_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_id and no external_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

    1. Check GET /api/v1/bnpl/loans/{id} for the loan's derivation_log. The most common reason for a skip is the coherence fallback — mixed currency or jurisdiction inside a per_consumer roll-up, which silently drops back to per-loan for the offending consumer.
    2. Confirm the loan has a non-null consumer_id or external_consumer_id.
    3. Confirm the loan is not marked obsolete=true — obsolete loans are filtered out of all future runs.
    4. 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

    1. Check the loan's installments — DOFD only fires if the oldest unresolved installment's due_at + threshold is in the past.
    2. Check the bnpl-dofd-watchdog cron log. The watchdog runs hourly; if it failed (DB blip, deploy in-flight), it will retry on the next hour.
    3. Confirm the installment state is missed / partial / scheduled, not waived or reversed (which count as resolved and do not fire DOFD).
    4. Confirm the policy's aggregation_strategy.dofd_method is set as you expect. The default is monthly_bucket (30-day threshold); if you intended strict on a specialty channel, the policy itself must say so.

    A translated record went to the wrong bureau

    1. Open the portfolio_id on 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.
    2. On that portfolio, list bureau routes (see Multi-Portfolio Routing). Confirm the right bureau is enabled and routed to the right format.
    3. 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

    1. Open the latest bnpl_translations row for the loan and read the derivation_log. Each rule firing is annotated with its inputs and outputs, including the pre-overlay PHP and the post-overlay PHP.
    2. Remember position 0 is the PREVIOUS month, not the current month. A loan that's active right now will show B at position 0 if no installments were due last month.
    3. 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, and terms_frequency for 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.