> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylon.mortgage/llms.txt
> Use this file to discover all available pages before exploring further.

# Income verification

> Learn how Pylon automatically verifies income and employment using Truv and The Work Number, eliminating manual documentation and borrower logins.

## What is income verification?

**Income verification** is the process of confirming a borrower's income and employment status to assess their ability to repay a mortgage loan. Lenders must verify that borrowers have sufficient, stable income to make monthly mortgage payments.

Traditionally, income verification has been a manual, time-consuming process requiring borrowers to:

* Upload pay stubs
* Provide employment letters
* Log into employer HRIS systems
* Submit tax returns and W-2s
* Manually enter income information

Pylon automates this entire process using **Day 1 Certainty (D1C) and Automated Income Model (AIM) approved services** that verify income and employment data automatically, reducing manual documentation and speeding up loan processing.

## Why D1C/AIM income verification matters

**Day 1 Certainty (D1C)** and **Automated Income Model (AIM)** are programs from Fannie Mae and Freddie Mac that allow lenders to use automated verification sources to reduce conditions and speed up loan processing.

When income data comes from D1C/AIM approved sources, it's considered a "verified" source, meaning no additional documentation is typically required, underwriting conditions are reduced, and loan processing is faster.

## Pylon's income verification integrations

Pylon has integrations with two D1C/AIM approved income verification vendors:

* **Truv** - Account connection-based income verification (requires borrower opt-in and your API call)
* **The Work Number (TWN)** - SSN-based income verification (fully automatic)

## Truv

**Truv** is an account connection-based income verification service that allows borrowers to connect their payroll or bank accounts to automatically verify income and employment data. Pylon is a reseller of Truv and will create an account and API credentials on your behalf.

Truv is driven entirely through Pylon's **GraphQL API**: you request a bridge token, initialize the [Truv Bridge](https://docs.truv.com/docs/bridge) widget with it, and then exchange the resulting public token back to Pylon. Pylon handles everything downstream: pulling the verification of income and employment (VOIE) report, mapping income entities, and populating the loan file.

### Truv flow at a glance

| Step                                | Who acts       | GraphQL operation                      |
| ----------------------------------- | -------------- | -------------------------------------- |
| 1. Request a bridge token           | You → Pylon    | `verify.createInitializationToken`     |
| 2. Initialize Truv Bridge           | You (frontend) | Truv widget                            |
| 3. Borrower connects accounts       | Borrower       | Truv widget                            |
| 4. Exchange the public token        | You → Pylon    | `verify.exchangePublicToken`           |
| 5. Pylon retrieves & maps VOIE data | Pylon (async)  | webhook-driven                         |
| 6. Read verified income             | You → Pylon    | `loan { borrowers { incomes } }` query |

<Info>
  The `borrowerId` you pass must belong to a loan your credentials can access.
</Info>

### How Truv works

<Steps>
  <Step title="Request a bridge token from Pylon">
    Call the `createInitializationToken` mutation (under the `verify` namespace) with `vendor: TRUV` and the borrower's ID. Pylon returns a `bridgeToken` used to initialize the [Truv Bridge](https://docs.truv.com/docs/bridge) widget.

    The mutation returns a `CreateInitializationTokenResponse` union, so select the `TruvBridgeToken` member with an inline fragment:

    ```graphql theme={null}
    mutation CreateTruvBridgeToken($input: CreateInitializationTokenInput!) {
      verify {
        createInitializationToken(input: $input) {
          ... on TruvBridgeToken {
            bridgeToken
          }
        }
      }
    }
    ```

    ```json Variables theme={null}
    {
      "input": {
        "vendor": "TRUV",
        "borrowerId": "borrower-123"
      }
    }
    ```

    **`CreateInitializationTokenInput` fields:**

    * `vendor` (`VendorName!`): `TRUV` (the `VendorName` enum is `TRUV` or `PLAID`)
    * `borrowerId` (`ID!`): the borrower being verified
    * `templateId`, `clientUserId` (`String`): not used for Truv; omit
  </Step>

  <Step title="Initialize the Truv Bridge widget">
    Pass the `bridgeToken` to the [Truv Bridge](https://docs.truv.com/docs/bridge) widget in your frontend. Truv Bridge is a pre-built UI component that handles employer/payroll lookup and the borrower's secure login.
  </Step>

  <Step title="Borrower opts in and connects accounts">
    The borrower selects their employer or payroll provider and authenticates through Truv's secure flow. This allows Truv to access payroll and employment information.
  </Step>

  <Step title="Exchange the public token with Pylon">
    When the borrower finishes, the Truv Bridge `onSuccess` callback hands your frontend a **public token** and metadata containing a Truv **`task_id`**. Pass both back to Pylon via the `exchangePublicToken` mutation to finalize the connection.

    ```graphql theme={null}
    mutation ExchangeTruvToken($input: ExchangePublicTokenInput!) {
      verify {
        exchangePublicToken(input: $input) {
          ... on TruvLinkAccess {
            linkId
          }
        }
      }
    }
    ```

    ```json Variables theme={null}
    {
      "input": {
        "vendor": "TRUV",
        "publicToken": "<public_token from onSuccess>",
        "borrowerId": "borrower-123",
        "truvMetadata": {
          "taskId": "<task_id from onSuccess metadata>",
          "employerName": "Acme Corp"
        }
      }
    }
    ```

    **`ExchangePublicTokenInput` fields:**

    * `vendor` (`VendorName!`): `TRUV`
    * `publicToken` (`String!`): the public token from the Bridge `onSuccess` callback
    * `borrowerId` (`ID!`): the same borrower from step 1
    * `truvMetadata` (`TruvExchangeMetadata`), **required when `vendor` is `TRUV`**:
      * `taskId` (`String!`): the Truv task ID from the `onSuccess` metadata
      * `employerName` (`String`): optional employer name
    * `plaidMetadata`: not used for Truv; omit

    The response is an `ExchangePublicTokenResponse` union; the `TruvLinkAccess` member returns `linkId` (the Truv task ID Pylon now tracks).

    <Warning>
      **You must call `exchangePublicToken` explicitly.** Unlike some embedded SDK integrations where the widget exchanges the token for you, building directly on the GraphQL API means your application is responsible for sending the public token and `taskId` to Pylon. Skipping this step means Pylon never retrieves the verification data.
    </Warning>
  </Step>

  <Step title="Pylon retrieves and maps the data">
    After the exchange, Pylon pulls the VOIE report from Truv **asynchronously** (Truv notifies Pylon via webhook when the report is ready). Pylon then maps all income types (variable income, commission, bonus, salary, and others) to the appropriate Pylon income entities. No data-mapping work is required on your end.
  </Step>

  <Step title="Read the verified data">
    Once mapping completes, the verified amount lands on each Income entity's `verifiedAmount` field, with a `voieReportId` referencing the source report. Poll the GraphQL API to detect completion (see [Checking income verification status](#checking-income-verification-status)). Since the data comes from Truv (a D1C and AIM approved source), no additional income documentation is typically required.
  </Step>
</Steps>

### End-to-end example (Node.js/TypeScript)

This example shows the two server-side calls that bookend the Truv Bridge widget: requesting the bridge token, and exchanging the public token your frontend receives from the `onSuccess` callback.

```typescript theme={null}
import { GraphQLClient, gql } from 'graphql-request';

const CREATE_TRUV_BRIDGE_TOKEN = gql`
  mutation CreateTruvBridgeToken($input: CreateInitializationTokenInput!) {
    verify {
      createInitializationToken(input: $input) {
        ... on TruvBridgeToken {
          bridgeToken
        }
      }
    }
  }
`;

const EXCHANGE_TRUV_TOKEN = gql`
  mutation ExchangeTruvToken($input: ExchangePublicTokenInput!) {
    verify {
      exchangePublicToken(input: $input) {
        ... on TruvLinkAccess {
          linkId
        }
      }
    }
  }
`;

const client = new GraphQLClient('https://api.pylon.mortgage/graphql', {
  headers: {
    Authorization: `Bearer ${process.env.PYLON_API_KEY}`,
  },
});

/**
 * Step 1: request a Truv bridge token for a borrower.
 * Hand the returned bridgeToken to the Truv Bridge widget in your frontend.
 */
async function createTruvBridgeToken(borrowerId: string): Promise<string> {
  const data = await client.request<{
    verify: { createInitializationToken: { bridgeToken: string } };
  }>(CREATE_TRUV_BRIDGE_TOKEN, {
    input: { vendor: 'TRUV', borrowerId },
  });

  return data.verify.createInitializationToken.bridgeToken;
}

/**
 * Step 4: exchange the public token returned by the Truv Bridge onSuccess callback.
 * The taskId comes from the onSuccess metadata; employerName is optional.
 */
async function exchangeTruvToken(params: {
  borrowerId: string;
  publicToken: string;
  taskId: string;
  employerName?: string;
}): Promise<string> {
  const data = await client.request<{
    verify: { exchangePublicToken: { linkId: string } };
  }>(EXCHANGE_TRUV_TOKEN, {
    input: {
      vendor: 'TRUV',
      publicToken: params.publicToken,
      borrowerId: params.borrowerId,
      truvMetadata: {
        taskId: params.taskId,
        employerName: params.employerName,
      },
    },
  });

  return data.verify.exchangePublicToken.linkId;
}

// Example usage
// 1. Server: mint a bridge token and send it to the browser
const bridgeToken = await createTruvBridgeToken('borrower-123');

// 2. Frontend: initialize Truv Bridge with `bridgeToken`. On success the widget
//    returns a public token and metadata containing a task_id.

// 3. Server: finalize the connection. Pylon then pulls the VOIE report and maps
//    income entities asynchronously; poll for `verifiedAmount` afterward.
const linkId = await exchangeTruvToken({
  borrowerId: 'borrower-123',
  publicToken: '<public_token from onSuccess>',
  taskId: '<task_id from onSuccess metadata>',
  employerName: 'Acme Corp',
});
```

### Truv characteristics

* **Client opt-in required**: Borrowers must opt in and connect their accounts through Truv's connection flow
* **You must drive it**: The flow runs through the `verify` mutations and the Truv Bridge widget; it does not happen automatically
* **Asynchronous completion**: Verification data is retrieved by Pylon in the background after the exchange; poll `verifiedAmount` to detect when it's ready
* **>95% coverage**: Truv has over 95% coverage of employers and payroll providers
* **Lower production costs**: Cheaper production costs compared to The Work Number
* **Pylon handles data mapping**: Pylon automatically maps all income types (variable, commission, bonus, salary, etc.) to appropriate Pylon fields; no work required on your end

## The Work Number (TWN)

**The Work Number (TWN)** is a comprehensive employment and income verification service that maintains a database of verified employment and payroll data from thousands of employers and payroll providers across the United States.

### How The Work Number works

<Steps>
  <Step title="Credit pull collects SSN">
    When you pull credit for a borrower, you collect their Social Security
    Number (SSN) as part of the credit pull process. This SSN is the only
    information needed for income verification through The Work Number.
  </Step>

  <Step title="Pylon automatically triggers verification">
    Pylon automatically triggers income verification through The Work Number in the background. No customer opt-in or flow is required to initialize-it happens automatically once the SSN is available.
  </Step>

  <Step title="Pylon queries The Work Number">
    Pylon uses the borrower's SSN to query The Work Number's
    database for verified income and employment data. This happens
    automatically-no API calls or requests from you are needed.
  </Step>

  <Step title="Pylon uses verified data">
    If the borrower's employer is covered by The Work Number, verified income
    and employment data is automatically retrieved and stored in the loan file.
    Pylon uses this verified data for underwriting and pricing calculations.
  </Step>

  <Step title="No additional documentation needed">
    Since the data comes from The Work Number (a D1C and AIM approved source),
    no additional income documentation is typically required. Pylon's underwriting system accepts the verified data without additional documentation.
  </Step>
</Steps>

### The Work Number characteristics

* **SSN-based**: Only requires the borrower's Social Security Number (collected during credit pull)
* **Fully automatic**: Pylon automatically triggers verification-no borrower opt-in, no flow to initialize, and no API calls required from you
* **Higher production costs**: Much higher production costs compared to Truv (in the range of 5x)
* **Charged on re-pull**: Costs are incurred on each verification pull, including re-pulls

### Programmatic configuration and cost considerations

<Warning>
  **Cost impact**: The Work Number is approximately 5x more expensive than other verification sources like Truv. Given this cost difference, it's important to be thoughtful about application-to-funded loan ratios when configuring The Work Number programmatically.
</Warning>

**Reach out to your account manager** to discuss programmatic configuration of The Work Number. Because The Work Number is charged on each verification pull (including re-pulls), and Pylon covers the cost of unfunded loans in the closed per loan fee, high application-to-funded loan ratios can significantly impact your per-funded-loan underwriting fee.

**Key considerations:**

* **Application-to-funded ratio**: If you have a high dropout rate (many applications that don't result in funded loans), the cost of The Work Number verifications per funded loan increases substantially
* **Per-funded-loan impact**: Since Pylon covers unfunded loan costs in the closed per loan fee, using The Work Number when you have a low application-to-funded ratio may require adjusting your per-funded-loan underwriting fee
* **Vendor waterfall strategy**: Consider implementing a vendor waterfall that prioritizes lower-cost options (like Truv) before falling back to The Work Number, especially if your application-to-funded ratio is low

Your account manager can help you:

* Configure when The Work Number should be triggered programmatically
* Understand the cost implications based on your application-to-funded ratio
* Implement vendor waterfall strategies to optimize costs
* Adjust pricing structures if needed

## Checking income verification status

After income verification is initiated (automatically for The Work Number, or after borrower opt-in for Truv), check the `verifiedAmount` field on the Income entity to see if verification was successful. When `verifiedAmount` has a value, income has been verified through Truv or The Work Number, and Pylon will use this verified data for underwriting without requiring additional documentation.

For complete details on querying income data, understanding income states (stated, qualified, verified), and all income fields, see the [Incomes entity guide](/entity-models/incomes#understanding-income-states-stated-qualified-and-verified).

### GraphQL query example

Poll Pylon's GraphQL API to check for verified income values. The `verifiedAmount` field will be populated when income has been successfully verified through Truv or The Work Number:

```graphql theme={null}
query GetVerifiedIncomes($loanId: ID!) {
  loan(id: $loanId) {
    borrowers(first: 10) {
      edges {
        node {
          id
          firstName
          lastName
          incomes(first: 100) {
            edges {
              node {
                id
                __typename
                statedMonthlyAmount
                verifiedAmount
                voieReportId
                ... on StandardEmploymentIncome {
                  employment {
                    ... on StandardEmployment {
                      employer {
                        name
                      }
                    }
                  }
                }
              }
            }
            pageInfo {
              hasNextPage
              endCursor
            }
          }
        }
      }
    }
  }
}
```

**Key fields:**

* `verifiedAmount`: The verified monthly income amount. `null` if not yet verified, or a number if verified through Truv or The Work Number
* `voieReportId`: The ID of the source verification of income and employment (VOIE) report. Populated alongside `verifiedAmount` when income is verified through Truv
* `statedMonthlyAmount`: The borrower's stated monthly income amount
* `__typename`: The concrete income kind (e.g., `StandardEmploymentIncome`), used to select the matching inline fragment

**Verification status:**

* `verifiedAmount === null`: Income has not been verified yet, or verification is not available
* `verifiedAmount > 0`: Income has been successfully verified through D1C/AIM approved sources

### Node.js/TypeScript polling example

Here's a complete example of polling for verified income values using Node.js and TypeScript:

```typescript theme={null}
import { GraphQLClient } from 'graphql-request';

const GET_VERIFIED_INCOMES = `
  query GetVerifiedIncomes($loanId: ID!) {
    loan(id: $loanId) {
      borrowers(first: 10) {
        edges {
          node {
            id
            firstName
            lastName
            incomes(first: 100) {
              edges {
                node {
                  id
                  __typename
                  statedMonthlyAmount
                  verifiedAmount
                  ... on StandardEmploymentIncome {
                    employment {
                      ... on StandardEmployment {
                        employer {
                          name
                        }
                      }
                    }
                  }
                }
              }
              pageInfo {
                hasNextPage
                endCursor
              }
            }
          }
        }
      }
    }
  }
`;

interface IncomeNode {
  id: string;
  __typename: string;
  statedMonthlyAmount: number | null;
  verifiedAmount: number | null;
  employment?: {
    employer?: {
      name: string;
    };
  };
}

interface BorrowerNode {
  id: string;
  firstName: string;
  lastName: string;
  incomes: {
    edges: Array<{
      node: IncomeNode;
    }>;
    pageInfo: {
      hasNextPage: boolean;
      endCursor: string | null;
    };
  };
}

interface LoanData {
  loan: {
    borrowers: {
      edges: Array<{
        node: BorrowerNode;
      }>;
    };
  };
}

/**
 * Polls for verified income values for a given loan
 * @param client - GraphQL client configured with your Pylon API endpoint and auth
 * @param loanId - The ID of the loan to check
 * @param maxAttempts - Maximum number of polling attempts (default: 30)
 * @param intervalMs - Polling interval in milliseconds (default: 2000)
 * @returns Promise resolving to verified incomes or null if verification not available
 */
async function pollForVerifiedIncomes(
  client: GraphQLClient,
  loanId: string,
  maxAttempts: number = 30,
  intervalMs: number = 2000
): Promise<IncomeNode[] | null> {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const data = await client.request<LoanData>(GET_VERIFIED_INCOMES, {
      loanId,
    });

    const allIncomes: IncomeNode[] = [];
    
    // Collect all incomes from all borrowers
    for (const borrowerEdge of data.loan.borrowers.edges) {
      const borrower = borrowerEdge.node;
      
      for (const incomeEdge of borrower.incomes.edges) {
        const income = incomeEdge.node;
        allIncomes.push(income);
        
        // Check if this income has been verified
        if (income.verifiedAmount !== null && income.verifiedAmount > 0) {
          console.log(
            `✓ Income verified for ${borrower.firstName} ${borrower.lastName}: ` +
            `$${income.verifiedAmount}/month (${income.__typename})`
          );
        }
      }
    }

    // Check if any incomes have been verified
    const verifiedIncomes = allIncomes.filter(
      (income) => income.verifiedAmount !== null && income.verifiedAmount > 0
    );

    if (verifiedIncomes.length > 0) {
      return verifiedIncomes;
    }

    // If no verified incomes yet, wait before next attempt
    if (attempt < maxAttempts - 1) {
      console.log(
        `No verified incomes yet (attempt ${attempt + 1}/${maxAttempts}). ` +
        `Polling again in ${intervalMs}ms...`
      );
      await new Promise((resolve) => setTimeout(resolve, intervalMs));
    }
  }

  console.log('Income verification not available after maximum attempts');
  return null;
}

// Example usage
const client = new GraphQLClient('https://api.pylon.mortgage/graphql', {
  headers: {
    Authorization: `Bearer ${process.env.PYLON_API_KEY}`,
  },
});

// Poll for verified incomes
pollForVerifiedIncomes(client, 'loan-123')
  .then((verifiedIncomes) => {
    if (verifiedIncomes) {
      console.log(`Found ${verifiedIncomes.length} verified income(s)`);
      // Use verified income data for your application logic
    } else {
      console.log('Income verification not available - fallback to traditional methods');
      // Fallback to requesting pay stubs, W-2s, etc.
    }
  })
  .catch((error) => {
    console.error('Error polling for verified incomes:', error);
  });
```

**Key implementation details:**

* **Polling strategy**: The function polls at regular intervals (default: every 2 seconds) until verified incomes are found or max attempts are reached
* **Verification check**: Checks if `verifiedAmount` is not null and greater than 0
* **Error handling**: Includes error handling and fallback logic for when verification isn't available
* **Multiple borrowers**: Handles loans with multiple borrowers and collects all incomes
* **Pagination**: The example shows pagination structure, though you may need to implement cursor-based pagination for loans with many incomes

## Benefits

### For borrowers

* **No document upload**: No need to upload pay stubs, employment letters, or tax documents when verification is successful
* **No HRIS login**: Borrowers don't need to log into their employer's HRIS system or payroll portal
* **Faster processing**: Automated verification is faster than manual document review

### For lenders

* **Reduced conditions**: Pylon's underwriting system generates fewer conditions when income is verified through D1C/AIM sources
* **Fewer borrower tasks**: Pylon creates fewer tasks for borrowers when verification is successful
* **Faster closes**: Pylon's automated processing accelerates loan processing and reduces time to close
* **Better accuracy**: Verified data from Truv or The Work Number is more accurate than manual entry or document review
* **Cost savings**: Reduced manual processing and document review costs (with Truv offering lower production costs)

## Limitations and fallbacks

### When automated verification doesn't work

Income verification through Truv or The Work Number may not be available if:

* **Truv opt-in not completed**: The borrower didn't opt in to Truv or didn't complete the account connection process
* **Employer not covered**: The borrower's employer doesn't participate in The Work Number
* **Self-employed borrowers**: Both Truv and The Work Number are primarily for W-2 employees. Self-employed borrowers require different verification methods (tax returns, profit & loss statements, etc.)
* **Recent employment changes**: If a borrower recently changed employers, verification services may not have the latest information yet
* **Contractor or gig workers**: Non-traditional employment arrangements may not be covered

### Fallback to traditional methods

When automatic verification isn't available, Pylon will request traditional income verification documentation from borrowers:

* **Pay stubs**: Recent pay stubs (typically 2-3 months)
* **Employment letters**: Employment verification letters from employers
* **Tax returns**: For self-employed borrowers, tax returns and profit & loss statements
* **W-2s and 1099s**: Tax documents for income verification
* **Bank statements**: Bank statements to verify income deposits

<Warning>
  **Always have a fallback**: Don't assume all borrowers will have income
  verified through Truv or The Work Number. Truv requires borrower opt-in, and The Work Number may not cover all employers. Always be prepared to use traditional
  verification methods when automated verification isn't available.
</Warning>

## Best practices

### Monitoring verification status

* **Poll regularly**: Check income verification status as part of your loan status polling workflow
* **For Truv**: Drive the flow through the `verify` GraphQL mutations: `createInitializationToken` to get a bridge token, then `exchangePublicToken` after the borrower completes Truv Bridge. Verification data is then retrieved asynchronously by Pylon
* **For The Work Number**: No action required-verification happens automatically once SSN is available
* **Handle failures gracefully**: If verification fails or isn't available, immediately switch to traditional verification methods

### Using verified data

* **Combine with other verifications**: Use Truv or The Work Number alongside other D1C/AIM approved sources (like Plaid for assets) for maximum benefit
* **Pylon updates pricing automatically**: Once income is verified, Pylon automatically recalculates pricing and eligibility, as verified income may affect loan terms

## Related resources

* [Incomes](/entity-models/incomes) - Complete guide to the Income entity, including all income types, states (stated, qualified, verified), and fields
* [Day 1 Certainty & AIM Overview](/guides/getting-started/order-outs-and-integrations/d1c-aim/overview) - Learn about D1C and AIM programs and their benefits
* [Asset Verification](/guides/getting-started/order-outs-and-integrations/d1c-aim/asset-verification) - Learn about Plaid for automatic asset verification
* [Credit Pulls](/guides/getting-started/order-outs-and-integrations/credit) - Understand credit pull requirements (SSN is collected here for income verification)
* [Order-Outs Overview](/guides/getting-started/order-outs-and-integrations/overview) - Understand when order-outs and verifications are triggered
