# Generate a loan tape for a single investor

## Overview

A loan tape is a scheduled CSV export that delivers loan or payment data to an investor over SFTP. When an investor owns only part of your portfolio, you scope a tape to that investor so its file contains only the loans associated with them. This runbook walks an integration engineer through configuring an investor-filtered tape, generating it, and confirming that the file was produced and delivered.

The filter is set once, when you create the tape: a tape carries a single `investorId`, and every time that tape generates, Peach includes only the loans currently associated with that investor.

This page covers the procedure end to end — from the prerequisite loan-investor association through confirming a delivered file. It does **not** cover:

- The full SFTP, encryption, and file-naming configuration — see [Set up data delivery](/data-reporting/data-delivery-setup).
- The Admin Portal equivalent of this API flow — see [Manage loan tapes and loan tape types](/servicing-operations/admin-portal/admin-portal-loan-tapes).
- The field-level contract for every tape column — see [Loan tape field reference](/data-reporting/loan-tape-reference) and the [Loan Tapes API](/api-docs/api-public/#tag/Loan-Tapes).


## Before you begin

### Prerequisites

- **An investor record** exists for your company, and you have its `investorId` (format `IR-XXXX-XXXX`). Retrieve it from the Admin Portal; the programmatic investor-list endpoint is Peach-internal, not part of the public API.


- **Each loan that should appear on the tape is associated with that investor.** A loan joins the tape only through an active `LoanInvestorAssociation`; loans with no association to the tape's investor are excluded. Set the association with `PUT /api/people/{personId}/loans/{loanId}/investors`:

```json
PUT /api/people/{personId}/loans/{loanId}/investors
Content-Type: application/json

{
  "effectiveDate": "2026-05-01",
  "investors": [
    {
      "investorId": "IR-7F3A-9K2D",
      "share": 1.0
    }
  ]
}
```
`investorId` and `share` are required per investor; `share` is the ownership fraction in decimal form, where `1.0` is 100% and `0.15` is 15%. This `share` is what the tape reports as `investorOwnershipPercentage`.


- **A loan tape type** is configured, or you create one in Step 1. The tape type defines which fields the CSV carries, its money unit, and its reporting rules.
- **An SFTP destination** is reachable, and a GPG public key is available if you encrypt the file. These credentials are supplied by the receiving investor or your own delivery team — Peach does not generate them.
- **Permissions** on your API credential: `loan.investors:update`, `loan.tape.type:create`, `loan.tape:create`, `loan.tape:generate`, and `loan.tape.record:list`.



### Assumed knowledge

- [Loan tape field reference](/data-reporting/loan-tape-reference) — the loan and payment columns a tape can carry, and where each comes from.
- [Snapshots and the replica database](/data-reporting/replica-database) — tapes read each loan's daily snapshot, so a loan with no snapshot for the report date cannot be reported.
- [Manage loan tapes and loan tape types](/servicing-operations/admin-portal/admin-portal-loan-tapes) — the Admin Portal view of the same objects.


## Key concepts

- **Loan tape type** — the reusable template that defines a tape's fields, money unit, and reporting rules. Many tapes can share one type.
- **Loan tape** — a single delivery configuration: one tape type plus one SFTP destination and, optionally, one `investorId` filter.
- **Investor association** — the `LoanInvestorAssociation` linking a loan to an investor with an ownership `share`; it is what the investor filter joins against.
- **Snapshot dependency** — each tape row is built from the loan's snapshot for the report date; missing snapshots are handled by the tape type's `actionOnMissingData` setting.
- **Asynchronous generation** — generating a tape fires a background job and returns immediately with an `operationId`; the file is produced and uploaded by a worker, and you observe completion through the tape's records.


## The procedure

The diagram below shows the full sequence: the prerequisite association, configuring the tape type and the investor-filtered tape, generating asynchronously, and monitoring the records.


```mermaid
sequenceDiagram
    participant L as Lender
    participant P as Peach API
    participant W as Tape worker

    Note over L,P: Prerequisite — associate loans with the investor
    L->>P: PUT /people/{personId}/loans/{loanId}/investors
    P-->>L: 204 (LoanInvestorAssociation set)

    Note over L,P: Configure
    L->>P: POST /companies/{companyId}/loan-tape-types
    P-->>L: 201 loanTapeTypeId (TY-...)
    L->>P: POST /companies/{companyId}/loan-tapes (investorId set)
    P-->>L: 201 loanTapeId (TA-...)

    Note over L,P: Generate (asynchronous)
    L->>P: POST /companies/{companyId}/loan-tapes/generate (reportDates)
    P-->>L: 201 [{ loanTapeId, operationId, reportDate }]
    P->>W: fire LoanTapeCreateFileEvent
    W->>W: join loans on LoanInvestorAssociation, build CSV, upload to SFTP

    Note over L,P: Monitor
    L->>P: GET /companies/{companyId}/loan-tapes/{loanTapeId}/records
    P-->>L: records with status (null → created → succeeded / failed)
```

### Step 1: Create or select the loan tape type

A tape type fixes the column set and reporting rules. Create one if you don't already have a suitable type, or list existing types with `GET /api/companies/{companyId}/loan-tape-types` and reuse one.


```json
POST /api/companies/{companyId}/loan-tape-types
Content-Type: application/json

{
  "tapeName": "Investor portfolio — loans",
  "tapeType": "loans",
  "fields": [
    "reportDate",
    "loanId",
    "investorId",
    "investorOwnershipPercentage",
    "loanStatus",
    "outstandingBalanceTotalAmount"
  ],
  "moneyUnit": "subUnit100",
  "reportDaysWhenPaidOff": 10
}
```

`tapeType`, `tapeName`, and `fields` are required. Amounts default to `subUnit100` (integer pennies); set `moneyUnit` to `fullUnit` for decimal dollars. `reportDaysWhenPaidOff` has a minimum of 10, and any lower value is raised to 10.

For the complete field catalog and per-field meanings, see the [Loan tape field reference](/data-reporting/loan-tape-reference) — do not enumerate all fields here.

**Expected result:** The response returns `201` with the new `loanTapeTypeId` (format `TY-XXXX-XXXX`). Save it for Step 2.

### Step 2: Create the investor-filtered loan tape

Create the tape that ties the tape type to a delivery destination, and set `investorId` to scope it to one investor. This is the switch that turns a portfolio-wide tape into an investor-filtered one.


```json
POST /api/companies/{companyId}/loan-tapes
Content-Type: application/json

{
  "loanTapeTypeId": "TY-4M8X-2P9Q",
  "investorId": "IR-7F3A-9K2D",
  "sftpHostname": "sftp.example-investor.com",
  "sftpAccessMethod": "usernamePassword",
  "sftpUsername": "<your-sftp-username>",
  "sftpPassword": "<your-sftp-password>"
}
```

`loanTapeTypeId` and `sftpHostname` are required. When `sftpAccessMethod` is `usernamePassword` (the default), supply `sftpUsername` and `sftpPassword`. To encrypt the file, set `encryptionMethod` to `GPG` and provide the investor's `publicKey`.

Never paste real credentials
`sftpPassword`, `sftpUsername`, and `publicKey` are real request fields that carry secrets supplied by the receiving investor. Use a secret manager and clearly-marked placeholders in any shared example. Never commit a literal password or key to a repository, ticket, or document.

Leaving `investorId` unset produces a tape covering every loan for the company; setting it restricts the tape to that one investor for the life of the tape.

**Expected result:** The response returns `201` with the new `loanTapeId` (format `TA-XXXX-XXXX`) and echoes the `investorId` you set. Save the `loanTapeId` for Step 3 and for monitoring.

## Execute

Generate the tape for one or more report dates. Generation is **asynchronous**: the call validates the request, fires one background job per tape and report date, and returns immediately. It does not return the file.


```json
POST /api/companies/{companyId}/loan-tapes/generate
Content-Type: application/json

{
  "loanTapeIds": ["TA-DJEK-CVDE"],
  "reportDates": ["2026-05-31"],
  "forceReplace": false,
  "uploadFile": true
}
```

`reportDates` is required (ISO `YYYY-MM-DD`). If you omit `loanTapeIds`, Peach generates every tape configured for the company. `forceReplace` (default `false`) creates a new record even when one already exists for that tape and date; `uploadFile` (default `true`) controls whether the worker uploads the file to SFTP after building it.

**Expected result:** The response returns `201` with one entry per tape and date, each carrying a `loanTapeId`, the `reportDate`, and an `operationId` you can use to trace the background job.


```json
{
  "data": [
    {
      "loanTapeId": "TA-DJEK-CVDE",
      "reportDate": "2026-05-31",
      "operationId": "8c1f0e6a-2d4b-4f9a-9b27-1a2b3c4d5e6f"
    }
  ]
}
```

## After completion

### Validate

Poll the tape's records to track the job and confirm delivery. Records are returned sorted by `createdAt` descending, so the most recent generation is first.


```http
GET /api/companies/{companyId}/loan-tapes/{loanTapeId}/records
```

Read the record's `status` to determine where the job is:

| `status` | Meaning |
|  --- | --- |
| `null` | Generation is in progress — the record was created when the job triggered and has not reached a terminal state. |
| `created` | The file was built but has not yet been uploaded to SFTP. |
| `succeeded` | The tape was generated and, if configured, uploaded. |
| `failed` | Generation failed; read `failureDescription` for the reason. |


The status moves through these states as the worker runs:


```mermaid
stateDiagram-v2
    [*] --> InProgress: generate fired (status null)
    InProgress --> Created: file built, awaiting SFTP upload
    InProgress --> Succeeded: file built, upload skipped (uploadFile=false)
    Created --> Succeeded: uploaded to SFTP
    InProgress --> Failed: see failureDescription
    Created --> Failed: upload failed
    Succeeded --> [*]
    Failed --> [*]
```

When `status` is `succeeded`, confirm:

- `tapeFileName` is populated and matches the file delivered to the SFTP destination.
- The record's `investorId` matches the investor you scoped the tape to.
- The file contains only loans associated with that investor. A loan that should appear but is missing almost always lacks an active `LoanInvestorAssociation` to this investor.


### System behavior after generation

- **Generation runs in a worker.** The API call only triggers the job. The worker resolves the loan set, builds the CSV, and uploads it; you observe progress through the records, not the generate response.
- **Loan inclusion is status-scoped.** A loan is included when its status is `Active`, `Accelerated`, `Frozen`, or `ChargedOff`, or when it is `PaidOff` and was paid off within the tape type's `reportDaysWhenPaidOff` window.
- **Missing snapshot data is governed by `actionOnMissingData`.** The effective default is `includeIDsAndEmptyData`: a loan with no snapshot for the report date is still reported, with only its identifier columns populated and `dataStatus` set to `missingData`. With `excludeFromTape`, such loans are dropped from the file entirely.

- **Line-of-credit draws** can be reported as separate rows when the tape type sets `shouldReportDrawsSeparately`; otherwise an LOC is reported on an aggregated level.


### Known limitations

- **The investor filter depends on the association being present and not soft-deleted.** *Known limitation.* The worker joins loans to the investor through `LoanInvestorAssociation` and excludes associations with a `deleted_at` value. A loan removed from the investor, or never associated, silently does not appear — the tape does not warn you.
- **Generation is asynchronous.** *By design.* The generate call returns `operationId`s, not a file. Treat the records `status` as the source of truth for completion.
- **One tape filters to one investor.** *By design.* `investorId` is a single, nullable value on the tape. To deliver to several investors, create one tape per investor; leave `investorId` blank only when you want every company loan on one file.

- **`excludeFromTape` shifts validation onto you.** *By design.* When a tape type uses `excludeFromTape`, loans with missing data are dropped without notice, so you are responsible for reconciling the loan count against your own records.
- **Membership-fee columns are gated.** *On the roadmap.* The `*MembershipFeesAmount` tape fields populate only when the membership-fee feature is enabled.



## If it fails

- **`status` is `failed`.** Read `failureDescription` on the record. A common cause is an SFTP problem — an unreachable host, a rejected credential, or a missing target directory. Correct the tape's SFTP settings (or the destination), then regenerate.
- **The tape is empty or missing expected loans.** The loans most likely have no active `LoanInvestorAssociation` to this investor, or they fall outside the included statuses, or they have no snapshot for the report date and the tape type uses `excludeFromTape`. Verify the associations first, then the loan statuses, then the snapshots.
- **A record already exists for that tape and date.** Re-running generate without `forceReplace` will not overwrite an existing record. Set `forceReplace: true` to create a fresh record for the same date.
- **Generation never reaches a terminal state.** A `status` that stays `null` for an extended period points to a stalled worker job; use the `operationId` from the generate response when contacting Peach support.


## Troubleshooting

**Why does the tape include fewer loans than I expect?**
A loan appears only if it has an active association to the tape's investor *and* falls within the included statuses *and* (under `excludeFromTape`) has a snapshot for the report date. Check those three conditions in that order. 

**I generated the tape but no file arrived at the SFTP destination.**
Check the record `status`. If it is `created`, the file was built but the upload has not completed or failed; if it is `failed`, read `failureDescription`. Confirm you generated with `uploadFile: true` — `false` builds the record without delivering a file. 

**Can I change which investor a tape reports?**
The `investorId` is part of the tape's configuration. Update the tape, or create a new tape for the other investor. The association data itself is changed through `PUT /api/people/{personId}/loans/{loanId}/investors`. 

**The amounts look off by a factor of 100.**
Amounts default to `subUnit100` (pennies). Set the tape type's `moneyUnit` to `fullUnit` for decimal dollars. 

## API quick reference

| Action | Method | Endpoint |
|  --- | --- | --- |
| Associate loan with investor | `PUT` | `/api/people/{personId}/loans/{loanId}/investors` |
| Create loan tape type | `POST` | `/api/companies/{companyId}/loan-tape-types` |
| List loan tape types | `GET` | `/api/companies/{companyId}/loan-tape-types` |
| Create loan tape (set `investorId`) | `POST` | `/api/companies/{companyId}/loan-tapes` |
| Generate loan tapes | `POST` | `/api/companies/{companyId}/loan-tapes/generate` |
| List loan tape records | `GET` | `/api/companies/{companyId}/loan-tapes/{loanTapeId}/records` |


For full field contracts, see the [Loan Tapes API](/api-docs/api-public/#tag/Loan-Tapes), [Loan Tape Types API](/api-docs/api-public/#tag/Loan-Tape-Types), and [Loan investors API](/api-docs/api-public/#tag/LoansInvestors).

## See also

- [Loan tape field reference](/data-reporting/loan-tape-reference) — every loan and payment column a tape can carry.
- [Snapshot field reference](/data-reporting/snapshot-reference) — the daily loan state that tape rows are built from.
- [Set up data delivery](/data-reporting/data-delivery-setup) — SFTP, encryption, and file-naming configuration.
- [Manage loan tapes and loan tape types](/servicing-operations/admin-portal/admin-portal-loan-tapes) — the Admin Portal equivalent of this flow.
- [Loan Tapes API](/api-docs/api-public/#tag/Loan-Tapes) — the canonical endpoint reference.