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

# Payroll Scenarios

> Pair a payroll login with the employment, paystub, W-2, and bank account data Truv should return for it

Set the payroll data a sandbox verification returns. A `payroll` scenario pairs a login with the employments, paystubs, W-2s, bank accounts, and shifts you want back, and serves them when the user signs in with that login in Truv Bridge. It drives every payroll product: Income & Employment, Employment History, Direct Deposit Switch, and Paycheck Linked Lending.

Use it when the built-in [payroll credentials](https://docs.truv.com/developers/testing/income-employment.md) don't cover the case you need: a specific pay frequency, a terminated employment, a second job, or income near your approval threshold. For the shared concepts, read [Custom Sandbox Data](https://docs.truv.com/developers/testing/custom-sandbox-data.md) first.

***

## Create a scenario \[Server-side]

> [!WARNING]
>
> Truv's built-in payroll logins are reserved. Don't use `goodlogin` or `error.user`, whatever the password: the API refuses them with `reserved_credential`. The reserved set grows as Truv publishes new test credentials, so use a distinctive username prefix, such as `acme.`.

This request creates a scenario with one salaried employment and one paystub:

```bash theme={null}
curl --request POST \
     --url https://prod.truv.com/v1/sandbox/scenarios/ \
     --header 'X-Access-Client-Id: YOUR_TRUV_CLIENT_ID' \
     --header 'X-Access-Secret: YOUR_TRUV_SANDBOX_SECRET' \
     --header 'Content-Type: application/json' \
     --data '{
  "data_source": "payroll",
  "label": "Acme — salaried, biweekly",
  "username": "acme.salaried",
  "password": "s4ndb0x",
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "Jane Roe", "ssn": "991-91-9991" },
        "company": { "name": "Acme Inc.", "domain": "acme.com" },
        "job_title": "Staff Engineer",
        "job_type": "F",
        "start_date": "2022-03-01",
        "is_active": true,
        "income": "124800.00",
        "income_unit": "YEARLY",
        "income_currency": "USD",
        "pay_frequency": "BW",
        "paystubs": [
          {
            "pay_date": "2026-08-14",
            "period_start": "2026-08-01",
            "period_end": "2026-08-14",
            "currency": "USD",
            "gross_pay": "4800.00",
            "net_pay": "3312.00",
            "basis_of_pay": "S"
          }
        ]
      }
    ]
  }
}'
```

> [!NOTE]
>
> Send `"data_source": "payroll"` explicitly. `username` and `password` are supported on payroll, financial accounts, and insurance, so Truv can't infer the data source from them and refuses the request with `data_source_required`. Only `company_domain` fits payroll alone, so a scenario that sets it can leave `data_source` out.

### Use it in Bridge

[Start a sandbox verification](https://docs.truv.com/developers/testing/test-credentials.md) the way you normally do. In Truv Bridge, select **Truv Payroll Provider** and sign in with `acme.salaried` / `s4ndb0x`. The task completes with your data instead of Truv's built-in test data. Read the result the same way as for any other connection: from the [task webhooks](https://docs.truv.com/api-reference/webhooks/object.md) and the [income and employment report](https://docs.truv.com/api-reference/user-income-and-employment-reports/object.md).

The scenario above sets neither `provider_id` nor `company_domain`, so any payroll provider works in sandbox. If your scenario sets either field, use a connection that matches those values.

> [accordion] Full sample: profile, bank account, and two paystubs with year-to-date totals
>
> This request fills every section of an employment. It creates a separate scenario, `acme.full`, so it doesn't collide with the one above.
>
> ```bash theme={null}
> curl --request POST \
>      --url https://prod.truv.com/v1/sandbox/scenarios/ \
>      --header 'X-Access-Client-Id: YOUR_TRUV_CLIENT_ID' \
>      --header 'X-Access-Secret: YOUR_TRUV_SANDBOX_SECRET' \
>      --header 'Content-Type: application/json' \
>      --data '{
>   "data_source": "payroll",
>   "label": "Acme — salaried, full profile",
>   "description": "Full-time salaried user with two paystubs",
>   "products": ["income"],
>   "username": "acme.full",
>   "password": "s4ndb0x",
>   "payload": {
>     "employments": [
>       {
>         "profile": {
>           "full_name": "Jane Roe",
>           "first_name": "Jane",
>           "last_name": "Roe",
>           "ssn": "991-91-9991",
>           "date_of_birth": "1988-04-12",
>           "email": "jane.roe@acme.com",
>           "phone": "4155554193",
>           "home_address": {
>             "street": "1 Main St",
>             "city": "Austin",
>             "state": "TX",
>             "zip": "78701",
>             "country": "US"
>           }
>         },
>         "company": {
>           "name": "Acme Inc.",
>           "ein": "123456789",
>           "domain": "acme.com"
>         },
>         "job_title": "Staff Engineer",
>         "job_type": "F",
>         "start_date": "2022-03-01",
>         "is_active": true,
>         "income": "124800.00",
>         "income_unit": "YEARLY",
>         "income_currency": "USD",
>         "pay_frequency": "BW",
>         "bank_accounts": [
>           {
>             "bank_name": "Acme CU",
>             "account_name": "Everyday",
>             "account_number": "1234567890",
>             "routing_number": "111000025",
>             "account_type": "C",
>             "deposit_type": "E"
>           }
>         ],
>         "paystubs": [
>           {
>             "pay_date": "2026-08-14",
>             "period_start": "2026-08-01",
>             "period_end": "2026-08-14",
>             "currency": "USD",
>             "gross_pay": "4800.00",
>             "net_pay": "3312.00",
>             "gross_pay_ytd": "76800.00",
>             "net_pay_ytd": "52992.00",
>             "basis_of_pay": "S",
>             "earnings": [
>               { "name": "Regular", "amount": "4800.00", "rate": "60.00", "units": "80.00" }
>             ],
>             "earnings_ytd": [
>               { "name": "Regular", "amount": "76800.00", "rate": "60.00", "units": "1280.00" }
>             ],
>             "deductions": {
>               "Federal Tax": "1056.00",
>               "Social Security": "297.60",
>               "Medicare": "69.60",
>               "State Tax": "64.80"
>             },
>             "deductions_ytd": {
>               "Federal Tax": "16896.00",
>               "Social Security": "4761.60",
>               "Medicare": "1113.60",
>               "State Tax": "1036.80"
>             }
>           },
>           {
>             "pay_date": "2026-08-28",
>             "period_start": "2026-08-15",
>             "period_end": "2026-08-28",
>             "currency": "USD",
>             "gross_pay": "4800.00",
>             "net_pay": "3312.00",
>             "gross_pay_ytd": "81600.00",
>             "net_pay_ytd": "56304.00",
>             "basis_of_pay": "S",
>             "earnings": [
>               { "name": "Regular", "amount": "4800.00", "rate": "60.00", "units": "80.00" }
>             ],
>             "earnings_ytd": [
>               { "name": "Regular", "amount": "81600.00", "rate": "60.00", "units": "1360.00" }
>             ],
>             "deductions": {
>               "Federal Tax": "1056.00",
>               "Social Security": "297.60",
>               "Medicare": "69.60",
>               "State Tax": "64.80"
>             },
>             "deductions_ytd": {
>               "Federal Tax": "17952.00",
>               "Social Security": "5059.20",
>               "Medicare": "1183.20",
>               "State Tax": "1101.60"
>             }
>           }
>         ]
>       }
>     ]
>   }
> }'
> ```

### The response

The response is the stored scenario, with the fields you left out filled in with their defaults. The request sent no behavior fields, so the task completes with `done`, with no delay and no MFA. The `payload` is abbreviated here; the API returns it in full.

```json theme={null}
{
  "id": "7b9f0c2a4d6e4f1b8c3a5d7e9f0b1c2d",
  "data_source": "payroll",
  "label": "Acme — salaried, biweekly",
  "description": "",
  "products": [],
  "enabled": true,
  "username": "acme.salaried",
  "password": "s4ndb0x",
  "provider_id": null,
  "company_domain": null,
  "is_refresh": null,
  "filenames": [],
  "ssn_last4": null,
  "date_of_birth": null,
  "behavior_status": "done",
  "behavior_error_message": "",
  "behavior_duration_seconds": 0,
  "behavior_mfa": {},
  "payload": { "employments": ["..."] },
  "created_at": "2026-09-17T10:04:11.482913Z",
  "updated_at": "2026-09-17T10:04:11.482931Z"
}
```

`filenames`, `ssn_last4`, and `date_of_birth` are matching fields for other data sources, so they stay empty on a payroll scenario.

***

## Matching fields

These fields decide which login the scenario answers. A field you leave out narrows nothing, so it matches any value. Set at least one of `username`, `password`, `provider_id`, or `company_domain`; `is_refresh` doesn't count on its own.

| Field | Type | Description |
| - | - | - |
| `username` | string | The login the user types. The match is exact: a user who types `acme.salaried@domain.com` doesn't match an `acme.salaried` scenario and gets Truv's built-in test data instead. |
| `password` | string | The password the user types. Omit it to match on username alone. |
| `provider_id` | string | Narrows the match to one payroll provider, e.g. `adp`. |
| `company_domain` | string | Narrows the match to one company, e.g. `acme.com`. |
| `is_refresh` | boolean | `true` matches data refreshes only, `false` initial verifications only. `null` (the default) matches both. |

`products` restricts the scenario to a subset of `income`, `employment`, `deposit_switch`, and `pll`. Empty, the default, matches every payroll product.

### Behavior

A payroll scenario supports every `behavior_status` and replays `behavior_mfa` at login. The payroll sandbox acts on every status except `unable_to_reset`, which it treats as a successful login. See [Behavior](https://docs.truv.com/developers/testing/custom-sandbox-data.md#behavior) for the fields.

***

## Payload reference

The `payload` object contains an `employments` array and optional `flags`. The tables below describe the fields in each employment record. Every field is optional unless noted: send only what your test needs, and leave the rest out.

> [accordion] employments\[]
>
> | Field | Type | Description |
> | - | - | - |
> | `profile` | object | User identity. See below. |
> | `company` | object | Employer record. See below. |
> | `job_title` | string | |
> | `job_type` | string | `F` full time, `P` part time, `S` seasonal, `D` per diem, `C` contract, `V` volunteer. |
> | `start_date` | date | ISO `YYYY-MM-DD`. |
> | `end_date` | date | On or after `start_date` when both are set. Set it to author a terminated employment. |
> | `original_hire_date` | date | |
> | `is_active` | boolean | |
> | `manager_name` | string | |
> | `income` | decimal string | Income amount expressed in `income_unit`. |
> | `income_unit` | string | `YEARLY`, `MONTHLY`, `WEEKLY`, `DAILY`, `HOURLY`. |
> | `income_currency` | string | ISO 4217 code, e.g. `USD`. |
> | `pay_rate` | decimal string | Kept only when `income_unit` is `YEARLY`; dropped otherwise. |
> | `pay_frequency` | string | `M` monthly, `SM` semimonthly, `W` weekly, `BW` biweekly, `A` annually, `SA` semiannually, `C` commission. For a salaried employment, set the real cadence (`BW`, `SM`, …): `A` with `income_unit: "YEARLY"` returns a null pay frequency. |
> | `bank_accounts` | array | Direct-deposit allocations. |
> | `paystubs` | array | Pay statements. |
> | `w2s` | array | W-2s. Each one needs a file. |
> | `shifts` | array | Scheduled shifts and time entries. |

> [accordion] employments\[].profile
>
> | Field | Type | Description |
> | - | - | - |
> | `full_name` | string | |
> | `first_name` | string | |
> | `last_name` | string | |
> | `ssn` | string | `NNN-NN-NNNN` with an area number of 900–999. Anything else is refused. |
> | `date_of_birth` | date | |
> | `email` | string | |
> | `phone` | string | |
> | `home_address` | object | `street`, `city`, `state`, `zip`, `country`. |

> [accordion] employments\[].company
>
> | Field | Type | Description |
> | - | - | - |
> | `name` | string | |
> | `ein` | string | |
> | `domain` | string | |
> | `phone` | string | |
> | `address` | object | `street`, `city`, `state`, `zip`, `country`. |

> [accordion] employments\[].paystubs\[]
>
> | Field | Type | Description |
> | - | - | - |
> | `pay_date` | date | Required. |
> | `period_start` | date | |
> | `period_end` | date | |
> | `currency` | string | ISO 4217 code. |
> | `gross_pay` | decimal string | |
> | `net_pay` | decimal string | |
> | `gross_pay_ytd` | decimal string | |
> | `net_pay_ytd` | decimal string | |
> | `hours` | decimal string | |
> | `basis_of_pay` | string | `S` salary, `H` hourly, `W` weekly, `D` daily, `M` monthly, `C` contract. |
> | `earnings` | array | Objects of `name` (required), `amount`, `rate`, `units`. |
> | `earnings_ytd` | array | Same shape as `earnings`, with `name` required. |
> | `deductions` | object | Name-keyed amounts, e.g. `{"Federal Tax": "938.17"}`. |
> | `deductions_ytd` | object | Same shape as `deductions`. |
> | `document_id` | string | Library entry to attach as the paystub PDF. See [Attach PDFs](#attach-pdfs). |

> [accordion] employments\[].bank\_accounts\[]
>
> | Field | Type | Description |
> | - | - | - |
> | `bank_name` | string | |
> | `account_name` | string | |
> | `account_number` | string | |
> | `routing_number` | string | |
> | `account_type` | string | `C` checking, `S` savings. |
> | `deposit_type` | string | `E` entire, `P` percent, `A` amount. |
> | `deposit_value` | decimal string | The percentage or amount, depending on `deposit_type`. |

> [accordion] employments\[].w2s\[]
>
> | Field | Type | Description |
> | - | - | - |
> | `year` | string | Required, 4 characters. |
> | `fields` | object | Per-box values, e.g. `{"wages": "119996.12"}`. |
> | `document_id` | string | **Required.** A W-2 with no file behind it is dropped when the task is served. See [Attach PDFs](#attach-pdfs). |

> [accordion] employments\[].shifts\[]
>
> | Field | Type | Description |
> | - | - | - |
> | `start_date` | date | |
> | `end_date` | date | |
> | `timezone` | string | IANA zone, e.g. `America/Chicago`. |
> | `time_entries` | array | Objects of `entry_date` (required), `start`, `end` (24-hour `HH:MM`). |

> [accordion] flags
>
> Fraud markers reported alongside the data, at the top level of the payload next to `employments`.
>
> | Field | Type | Description |
> | - | - | - |
> | `is_suspicious` | boolean | Default `false`. Flags the connection as suspicious: the link reports `is_suspicious: true`, and the task still completes with your data. |
> | `is_fraudulent` | boolean | Default `false`. Used by [document upload scenarios](https://docs.truv.com/developers/testing/custom-sandbox-data/document-upload.md). It has no effect on a payroll task. |

> [!NOTE]
>
> Money and quantities are decimal **strings**, so nothing is lost to floating point: `"4615.38"`, not `4615.38`.

***

## Examples

Each example is a request body for `POST /v1/sandbox/scenarios/` and creates a new scenario. Send it with the same `curl` command as [the first scenario](#create-a-scenario-server-side). The hourly, two-job, terminated, and failing-login bodies are complete. The rest are abbreviated to the fields the example is about, and each one says so above its code block.

### Hourly employee with overtime

Express an hourly wage as `income` plus `income_unit: "HOURLY"`, and put the hours on the paystub.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Acme — hourly with overtime",
  "username": "acme.hourly",
  "password": "s4ndb0x",
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "John Doe", "ssn": "991-91-9992" },
        "company": { "name": "Acme Inc.", "domain": "acme.com" },
        "job_title": "Line Cook",
        "job_type": "F",
        "start_date": "2024-01-15",
        "is_active": true,
        "income": "30.00",
        "income_unit": "HOURLY",
        "income_currency": "USD",
        "pay_frequency": "BW",
        "paystubs": [
          {
            "pay_date": "2026-08-14",
            "period_start": "2026-08-01",
            "period_end": "2026-08-14",
            "gross_pay": "2850.00",
            "net_pay": "1966.50",
            "gross_pay_ytd": "45600.00",
            "net_pay_ytd": "31464.00",
            "hours": "90.00",
            "basis_of_pay": "H",
            "earnings": [
              { "name": "Regular", "amount": "2400.00", "rate": "30.00", "units": "80.00" },
              { "name": "Overtime", "amount": "450.00", "rate": "45.00", "units": "10.00" }
            ],
            "earnings_ytd": [
              { "name": "Regular", "amount": "38400.00", "rate": "30.00", "units": "1280.00" },
              { "name": "Overtime", "amount": "7200.00", "rate": "45.00", "units": "160.00" }
            ],
            "deductions": {
              "Federal Tax": "627.00",
              "Social Security": "176.70",
              "Medicare": "41.33",
              "State Tax": "38.47"
            },
            "deductions_ytd": {
              "Federal Tax": "10032.00",
              "Social Security": "2827.20",
              "Medicare": "661.28",
              "State Tax": "615.52"
            }
          }
        ]
      }
    ]
  }
}
```

### Two employments

Return a second job by adding another entry to `employments`. Up to 10 fit in one scenario.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Two jobs — full time plus part time",
  "username": "acme.twojobs",
  "password": "s4ndb0x",
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "Jane Roe", "ssn": "991-91-9991" },
        "company": { "name": "Acme Inc.", "domain": "acme.com" },
        "job_title": "Staff Engineer",
        "job_type": "F",
        "start_date": "2022-03-01",
        "is_active": true,
        "income": "124800.00",
        "income_unit": "YEARLY",
        "pay_frequency": "BW"
      },
      {
        "profile": { "full_name": "Jane Roe", "ssn": "991-91-9991" },
        "company": { "name": "Northwind Retail", "domain": "northwind.com" },
        "job_title": "Sales Associate",
        "job_type": "P",
        "start_date": "2025-11-02",
        "is_active": true,
        "income": "22.50",
        "income_unit": "HOURLY",
        "pay_frequency": "W"
      }
    ]
  }
}
```

### Terminated employment

Set `end_date` and `is_active: false`. `end_date` can equal `start_date` but not precede it.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Terminated 6 months ago",
  "username": "acme.terminated",
  "password": "s4ndb0x",
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "Sam Doe", "ssn": "991-91-9993" },
        "company": { "name": "Acme Inc.", "domain": "acme.com" },
        "job_title": "Warehouse Associate",
        "job_type": "F",
        "start_date": "2021-06-14",
        "end_date": "2026-03-31",
        "is_active": false,
        "income": "48000.00",
        "income_unit": "YEARLY",
        "pay_frequency": "SM"
      }
    ]
  }
}
```

### Failing login

A failure status needs no payload. Use it to exercise the error paths in your integration.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Locked payroll account",
  "username": "acme.locked",
  "password": "s4ndb0x",
  "behavior_status": "account_locked",
  "behavior_error_message": "Your account has been locked after too many attempts.",
  "payload": {}
}
```

### Slow task

Delay the task before it finishes, up to 120 seconds, to test loading states and webhook ordering.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Slow payroll provider",
  "username": "acme.slow",
  "password": "s4ndb0x",
  "behavior_duration_seconds": 45,
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "Jane Roe" },
        "company": { "name": "Acme Inc." },
        "is_active": true,
        "income": "124800.00",
        "income_unit": "YEARLY"
      }
    ]
  }
}
```

### MFA challenge

`behavior_mfa.challenges` replays one screen per entry, in order. A `TEXT` challenge prompts for a code; an `OPTIONS` challenge renders a select. A challenge with neither `answer` nor `answers` accepts anything the user types.

```json theme={null}
{
  "data_source": "payroll",
  "label": "MFA — code then delivery method",
  "username": "acme.mfa",
  "password": "s4ndb0x",
  "behavior_mfa": {
    "challenges": [
      {
        "type": "TEXT",
        "label": "Enter the code we sent to your phone",
        "answer": "12345"
      },
      {
        "type": "OPTIONS",
        "label": "Where should we send your code?",
        "options": [
          { "value": "sms", "label": "Text message" },
          { "value": "email", "label": "Email" }
        ],
        "answers": ["sms", "email"]
      }
    ]
  },
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "Jane Roe" },
        "company": { "name": "Acme Inc." },
        "is_active": true,
        "income": "124800.00",
        "income_unit": "YEARLY"
      }
    ]
  }
}
```

Use `answers` when more than one response is acceptable. Up to 5 challenges per scenario.

### Refresh-only data

Set the data a [data refresh](https://docs.truv.com/developers/integration/embedded-orders/data-refresh.md) should find by restricting a scenario to refreshes with `is_refresh: true`. On a refresh it wins over the general scenario for the same login, because it sets `is_refresh` and the other doesn't. Create it alongside the `acme.salaried` scenario; don't update that one.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Acme — salaried, after a raise",
  "username": "acme.salaried",
  "password": "s4ndb0x",
  "is_refresh": true,
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "Jane Roe" },
        "company": { "name": "Acme Inc." },
        "is_active": true,
        "income": "137280.00",
        "income_unit": "YEARLY"
      }
    ]
  }
}
```

The user signs in once with `acme.salaried` and gets the original data; the refresh of that same connection gets the raised `income`.

### Restrict a scenario to one provider or employer

Add `provider_id` or `company_domain` to answer only connections that went through a specific provider or employer. This is how the same login returns different data depending on where the user connected. Create it alongside the `acme.salaried` scenario: the extra matching field makes it more specific, so it wins for ADP connections.

`company_domain` matches the employer the user selects through company search in Truv Bridge, the `domain` that [Search companies](https://docs.truv.com/api-reference/companies/company_mapping.md) returns. A user who picks a provider directly, such as **Truv Payroll Provider**, selects no employer, so a scenario that sets `company_domain` doesn't match their connection.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Acme — ADP connection only",
  "username": "acme.salaried",
  "password": "s4ndb0x",
  "provider_id": "adp",
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "Jane Roe" },
        "company": { "name": "Acme Inc." },
        "is_active": true,
        "income": "124800.00",
        "income_unit": "YEARLY"
      }
    ]
  }
}
```

***

## Attach PDFs

A paystub or W-2 can carry a real file. Upload it to your [sandbox document library](https://docs.truv.com/developers/testing/custom-sandbox-data.md#document-library) once, then reference it by id from as many scenarios as you like. A paystub's file is optional; **every W-2 needs one**, because a W-2 with nothing behind it is dropped when the task is served.

**1. Upload the paystub PDF**

Send the file as `multipart/form-data`. `filename` is a separate field: it is the name the entry is addressed by, and it does not have to match the name on disk.

```bash theme={null}
curl --request POST \
     --url https://prod.truv.com/v1/sandbox/documents/ \
     --header 'X-Access-Client-Id: YOUR_TRUV_CLIENT_ID' \
     --header 'X-Access-Secret: YOUR_TRUV_SANDBOX_SECRET' \
     --form 'filename=acme-paystub-2026-08-14.pdf' \
     --form 'file=@./acme-paystub-2026-08-14.pdf'
```

The response is the library entry. Keep the top-level `id`: that is the `document_id` your payload references.

```json theme={null}
{
  "id": "a41d7e9c05b24f6b8e3c1d5a7f92b604",
  "uploaded_file": {
    "file_id": "a41d7e9c05b24f6b8e3c1d5a7f92b604",
    "filename": "acme-paystub-2026-08-14.pdf",
    "mime_type": "application/pdf",
    "size": 48219,
    "md5": "9f2b1c0d4e6a8b3f5c7d9e1a2b4c6d8e",
    "file": "https://...",
    "status": "successful",
    "validations": {
      "is_viable_size": true,
      "is_supported_type": true,
      "is_accessible": true,
      "is_valid": true,
      "is_readable": true,
      "is_unique": true
    },
    "last_error": ""
  },
  "documents": [
    {
      "document_type": "PAYSTUB",
      "identity": { "full_name": "John Doe", "ssn": "991-91-9991" },
      "company_name": "Acme Inc."
    }
  ],
  "created_at": "2026-09-17T10:11:02.104882Z",
  "updated_at": "2026-09-17T10:11:02.104901Z"
}
```

> [!NOTE]
>
> `documents` is test metadata you configure for the file, not data extracted from it. You didn't send it, so the entry got the default: one paystub for John Doe at Acme Inc. Only [document upload scenarios](https://docs.truv.com/developers/testing/custom-sandbox-data/document-upload.md) read it. A payroll scenario serves the file and ignores it, so leave it out.

**2. Upload the W-2 PDF**

The same endpoint also takes a JSON body, with the bytes base64-encoded in `content`. Use whichever transport suits your client, and send exactly one of `file` or `content`, never both.

```bash theme={null}
curl --request POST \
     --url https://prod.truv.com/v1/sandbox/documents/ \
     --header 'X-Access-Client-Id: YOUR_TRUV_CLIENT_ID' \
     --header 'X-Access-Secret: YOUR_TRUV_SANDBOX_SECRET' \
     --header 'Content-Type: application/json' \
     --data "{\"filename\": \"acme-w2-2025.pdf\", \"content\": \"$(base64 < ./acme-w2-2025.pdf | tr -d '\n')\"}"
```

The response has the same shape as the multipart one. `validations` and `documents` are abbreviated here.

```json theme={null}
{
  "id": "3f1c8a90b7d2416e9a5c0e4f2b8d6a17",
  "uploaded_file": {
    "file_id": "3f1c8a90b7d2416e9a5c0e4f2b8d6a17",
    "filename": "acme-w2-2025.pdf",
    "mime_type": "application/pdf",
    "size": 22874,
    "md5": "c3e5a7091b2d4f6a8c0e2b4d6f81a3c5",
    "file": "https://...",
    "status": "successful",
    "validations": { "...": "..." },
    "last_error": ""
  },
  "documents": ["..."],
  "created_at": "2026-09-17T10:11:34.882104Z",
  "updated_at": "2026-09-17T10:11:34.882119Z"
}
```

**3. Reference both from a scenario**

Put each `id` on the record it belongs to: the paystub's file on the paystub, the W-2's file on the W-2.

```bash theme={null}
curl --request POST \
     --url https://prod.truv.com/v1/sandbox/scenarios/ \
     --header 'X-Access-Client-Id: YOUR_TRUV_CLIENT_ID' \
     --header 'X-Access-Secret: YOUR_TRUV_SANDBOX_SECRET' \
     --header 'Content-Type: application/json' \
     --data '{
  "data_source": "payroll",
  "label": "Acme — salaried, with documents",
  "username": "acme.documents",
  "password": "s4ndb0x",
  "payload": {
    "employments": [
      {
        "profile": { "full_name": "Jane Roe", "ssn": "991-91-9991" },
        "company": { "name": "Acme Inc.", "domain": "acme.com" },
        "job_title": "Staff Engineer",
        "job_type": "F",
        "start_date": "2022-03-01",
        "is_active": true,
        "income": "124800.00",
        "income_unit": "YEARLY",
        "income_currency": "USD",
        "pay_frequency": "BW",
        "paystubs": [
          {
            "pay_date": "2026-08-14",
            "period_start": "2026-08-01",
            "period_end": "2026-08-14",
            "currency": "USD",
            "gross_pay": "4800.00",
            "net_pay": "3312.00",
            "gross_pay_ytd": "76800.00",
            "net_pay_ytd": "52992.00",
            "basis_of_pay": "S",
            "earnings": [
              { "name": "Regular", "amount": "4800.00", "rate": "60.00", "units": "80.00" }
            ],
            "earnings_ytd": [
              { "name": "Regular", "amount": "76800.00", "rate": "60.00", "units": "1280.00" }
            ],
            "deductions": {
              "Federal Tax": "1056.00",
              "Social Security": "297.60",
              "Medicare": "69.60",
              "State Tax": "64.80"
            },
            "deductions_ytd": {
              "Federal Tax": "16896.00",
              "Social Security": "4761.60",
              "Medicare": "1113.60",
              "State Tax": "1036.80"
            },
            "document_id": "a41d7e9c05b24f6b8e3c1d5a7f92b604"
          }
        ],
        "w2s": [
          {
            "year": "2025",
            "fields": { "wages": "124800.00" },
            "document_id": "3f1c8a90b7d2416e9a5c0e4f2b8d6a17"
          }
        ]
      }
    ]
  }
}'
```

Sign in with `acme.documents` / `s4ndb0x` and the completed task carries both PDFs, downloadable through the same endpoints as any other connection: [Pay Statements](https://docs.truv.com/api-reference/statements/object.md) for the paystub and [Tax Documents](https://docs.truv.com/api-reference/tax/object.md) for the W-2.

***

## Limits

These are the default limits for payroll payloads. The account-wide limits are listed on [Custom Sandbox Data](https://docs.truv.com/developers/testing/custom-sandbox-data.md#limits).

| Limit | Default |
| - | - |
| Employments per scenario | 10 |
| Paystubs per employment | 60 |
| W-2s per employment | 5 |
| Bank accounts per employment | 5 |
| Shifts per employment | 50 |
| Time entries per shift | 50 |

***

## Next steps

> [card] [Income & Employment Testing](https://docs.truv.com/developers/testing/income-employment.md)
>
> Truv's built-in payroll credentials and sample reports

> [card] [Document upload scenarios](https://docs.truv.com/developers/testing/custom-sandbox-data/document-upload.md)
>
> Serve the same employment data from uploaded documents

> [card] [Data refresh](https://docs.truv.com/developers/integration/embedded-orders/data-refresh.md)
>
> How refreshes reach your `is_refresh` scenarios

> [card] [Sandbox API reference](https://docs.truv.com/api-reference/sandbox/object.md)
>
> Full request and response schemas for every sandbox endpoint


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.