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

# Custom Sandbox Data

> Author your own payroll sandbox responses — pair a login with the exact data Truv should return for it

Author your own payroll test data in sandbox. Create a *scenario* that pairs a login with the employment, paystub, W-2, and bank account data you want back, then sign in with that login in Truv Bridge to get it. Your scenarios sit alongside Truv's [built-in test data](/developers/testing/income-employment) — nothing you create replaces or hides it.

Use this when the shipped credentials don't cover the case you need to test: a specific pay frequency, a terminated employment, a second job, an income figure your decision engine sits right on the edge of. For the full request and response schemas, see the [Sandbox API reference](/api-reference/sandbox/object).

<Info>
  Scenarios are configuration, not verification data. They live on your account, are shared by every teammate using a sandbox key, and have no effect in production.
</Info>

***

## How it works

<Steps>
  <Step title="Create a scenario">
    `POST /v1/sandbox/scenarios/` with the login your user will type, a `payload` of the data to return, and optionally how the task should behave — status, delay, multi-factor authentication (MFA).
  </Step>

  <Step title="Run a verification">
    [Start a normal sandbox verification](/developers/testing/test-credentials). In Truv Bridge, select **Truv Payroll Provider** and sign in with the username and password you gave the scenario. If your scenario sets `provider_id` or `company_domain`, use a connection that matches those values.
  </Step>

  <Step title="Get your data back">
    Truv resolves the scenario and serves your payload as the task result. Tasks, webhooks, and reports behave exactly as they do for any other connection.
  </Step>
</Steps>

***

## Create a scenario \[Server-side]

A scenario is one flat object: what it matches on, how it behaves, and what it returns. The endpoints accept [sandbox keys](https://dashboard.truv.com/app/development/keys) only — a production key gets `403 env_not_sandbox`.

<Warning>
  Truv's built-in test credentials are reserved. For payroll scenarios, don't use `goodlogin` or `error.user`, whatever the password. Truv checks reserved credentials when you create or update a scenario and returns `reserved_credential` on a conflict.

  The reserved set grows as Truv publishes new test credentials. Use a distinctive username prefix, such as `acme.`, to reduce the risk of a conflict. Other data sources reserve their own built-in test data, listed under [Test Credentials](/developers/testing/test-credentials) and [Test Documents](/developers/testing/test-documents).
</Warning>

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"
          }
        ]
      }
    ]
  }
}'
```

### Use it in Bridge

[Start a sandbox verification](/developers/testing/test-credentials) 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](/api-reference/webhooks/object) and the [income and employment report](/api-reference/user-income-and-employment-reports/object).

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 title="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"
              }
            }
          ]
        }
      ]
    }
  }'
  ```
</Accordion>

### 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,
  "phone": 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"
}
```

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

<Note>
  Always send `"data_source": "payroll"`. `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`.
</Note>

***

## Scenario fields

### Identity and routing

| Field | Type | Description |
| - | - | - |
| `data_source` | string | `payroll` for everything on this page. You can't change it after creating a scenario. To use another data source, create a new scenario with that source and a compatible payload. |
| `label` | string | Required. How you recognize the scenario in a list. Unique per data source within your account. |
| `description` | string | Free-form notes. Up to 2000 characters. |
| `products` | array | Products the scenario applies to. Payroll scenarios use `income`, `employment`, `deposit_switch`, and `pll`. The API accepts other product values too, but a payroll task never carries them, so a scenario pinned only to them never matches. Empty (the default) matches every product. |
| `enabled` | boolean | Whether the scenario takes part in resolution. Default `true`. |

### How a scenario is selected

These fields decide which user input the scenario answers. A field you leave out narrows nothing, so it matches any value.

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

No two of your payroll scenarios can have the same matching fields — the API refuses a duplicate and names the scenario that already has them. `enabled` and `products` don't make two scenarios distinct: change a matching field, or edit the existing scenario instead.

<Warning>
  Set at least one of `username`, `password`, `provider_id`, or `company_domain`. The API refuses a scenario that sets none of them, because it would answer every payroll verification in your sandbox. `is_refresh` doesn't count on its own.
</Warning>

### Behavior

| Field | Type | Description |
| - | - | - |
| `behavior_status` | string | Terminal status of the task: `done`, `error`, `login_error`, `account_locked`, `mfa_error`, `config_error`, `no_data`, `unavailable`, `unable_to_reset`, `not_supported`. Default `done`. |
| `behavior_error_message` | string | Error message surfaced on the task. Up to 500 characters. |
| `behavior_duration_seconds` | integer | Artificial delay before the task settles. `0`–`120`. Default `0`. |
| `behavior_mfa` | object | MFA screens to replay before the data is returned. See [MFA challenge](#mfa-challenge). |

`payload` is required unless `behavior_status` is a failure status. A scenario that only ever fails carries no data.

See [Task lifecycle](/api-reference/tasks/lifecycle) for what each status means to your integration.

***

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

<AccordionGroup>
  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="employments[].company">
    | Field | Type | Description |
    | - | - | - |
    | `name` | string | |
    | `ein` | string | |
    | `domain` | string | |
    | `phone` | string | |
    | `address` | object | `street`, `city`, `state`, `zip`, `country`. |
  </Accordion>

  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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>

  <Accordion title="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`. Reserved for upcoming document-upload scenarios. It has no effect on a payroll task. |
  </Accordion>
</AccordionGroup>

<Note>
  Money and quantities are decimal **strings**, so nothing is lost to floating point: `"4615.38"`, not `4615.38`.
</Note>

***

## 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 settles, up to 120 seconds, to test loading states and webhook ordering.

Abbreviated: replace the `employments` placeholder with complete employment objects before sending this request.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Slow payroll provider",
  "username": "acme.slow",
  "password": "s4ndb0x",
  "behavior_duration_seconds": 45,
  "payload": { "employments": ["..."] }
}
```

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

Abbreviated: replace the `employments` placeholder with complete employment objects before sending this request.

```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": ["..."] }
}
```

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

### Refresh-only data

Author the state a [data refresh](/developers/integration/embedded-orders/data-refresh) should find by pinning a scenario to refreshes with `is_refresh: true`. On a refresh it wins over the scenario it shadows, because it sets `is_refresh` and the other doesn't. Create it alongside the `acme.salaried` scenario — don't update that one.

Abbreviated: replace the `employments` placeholder with complete employment objects before sending this request.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Acme — salaried, after a raise",
  "username": "acme.salaried",
  "password": "s4ndb0x",
  "is_refresh": true,
  "payload": { "employments": ["..."] }
}
```

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

### Pin 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](/api-reference/companies/company_mapping) 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.

Abbreviated: replace the `employments` placeholder with complete employment objects before sending this request.

```json theme={null}
{
  "data_source": "payroll",
  "label": "Acme — ADP connection only",
  "username": "acme.salaried",
  "password": "s4ndb0x",
  "provider_id": "adp",
  "payload": { "employments": ["..."] }
}
```

***

## Which scenario wins

Several scenarios can share a login on purpose. A [refresh-only](#refresh-only-data) or [provider-pinned](#pin-a-scenario-to-one-provider-or-employer) scenario shadows a general one for the same username.

When more than one scenario matches, Truv selects the scenario with more matching fields (`username`, `password`, `provider_id`, `company_domain`). `is_refresh` is supported on every data source, but it doesn't count toward matching fields. If there is still a tie, a scenario that sets `is_refresh` wins over one that doesn't. After that, Truv selects the oldest scenario.

Disabled scenarios never take part, and a scenario pinned to `products` is skipped for tasks of any other product. If nothing matches, the task falls through to Truv's built-in test data.

### Check the winner without running a verification

`POST /v1/sandbox/scenarios/resolve/` is a dry run: send the input a user would supply and get back the scenario that would answer it.

```bash theme={null}
curl --request POST \
     --url https://prod.truv.com/v1/sandbox/scenarios/resolve/ \
     --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",
  "username": "acme.salaried",
  "password": "s4ndb0x",
  "provider_id": "adp",
  "product": "income"
}'
```

```json theme={null}
{
  "scenario": { "id": "7b9f0c2a4d6e4f1b8c3a5d7e9f0b1c2d", "label": "Acme — ADP connection only", "...": "..." },
  "matched_on": ["password", "provider_id", "username"],
  "tier": "client"
}
```

`scenario` is abbreviated here; the API returns the full scenario. `matched_on` lists the fields the winning scenario actually constrained. A `404` means no scenario of yours matches and the verification would get Truv's built-in test data.

***

## Manage scenarios

### List and filter

```bash theme={null}
curl --request GET \
     --url 'https://prod.truv.com/v1/sandbox/scenarios/?data_source=payroll&enabled=true&product=income' \
     --header 'X-Access-Client-Id: YOUR_TRUV_CLIENT_ID' \
     --header 'X-Access-Secret: YOUR_TRUV_SANDBOX_SECRET'
```

The response is cursor-paginated — `{"results": [...], "next": "..."}`. Follow `next` until it is `null`. Results come back oldest first. Matching fields depend on the input, so use the [resolve endpoint](#check-the-winner-without-running-a-verification) to see the actual winner.

| Parameter | Values |
| - | - |
| `data_source` | `payroll`, `docs`, `financial_accounts`, `education`, `credit`, `insurance` |
| `product` | A product value, such as `income`, `employment`, `deposit_switch`, or `pll`. Scenarios with no `products` match every product and are always included. |
| `enabled` | `true` or `false` |

### Update

`PATCH` (or `PUT` — they behave identically) replaces every top-level key you send and leaves the rest untouched. `payload` is replaced wholesale, so send the complete object, not a fragment.

```bash theme={null}
curl --request PATCH \
     --url https://prod.truv.com/v1/sandbox/scenarios/SCENARIO_ID/ \
     --header 'X-Access-Client-Id: YOUR_TRUV_CLIENT_ID' \
     --header 'X-Access-Secret: YOUR_TRUV_SANDBOX_SECRET' \
     --header 'Content-Type: application/json' \
     --data '{ "enabled": false }'
```

You can't change `data_source` after creating a scenario. To use another data source, create a new scenario with that source and a compatible payload.

### Delete

```bash theme={null}
curl --request DELETE \
     --url https://prod.truv.com/v1/sandbox/scenarios/SCENARIO_ID/ \
     --header 'X-Access-Client-Id: YOUR_TRUV_CLIENT_ID' \
     --header 'X-Access-Secret: YOUR_TRUV_SANDBOX_SECRET'
```

***

## Attach PDFs

A paystub or W-2 can carry a real file. Upload it to your sandbox 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.

<Steps>
  <Step title="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 read it — a payroll scenario serves the file and ignores it, so leave it out.
    </Note>
  </Step>

  <Step title="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 — 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"
    }
    ```
  </Step>

  <Step title="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](/api-reference/statements/object) for the paystub and [Tax Documents](/api-reference/tax/object) for the W-2.
  </Step>
</Steps>

### Managing the library

`GET /v1/sandbox/documents/` lists your entries and `DELETE /v1/sandbox/documents/{id}/` removes one. A delete is refused with `409 resource_in_use` while any scenario still references the file, so drop the reference first. One upload serves any number of scenarios — reference the same `document_id` from all of them rather than uploading the file again.

Filenames are unique per account, compared without regard to case, and Truv's own documented sandbox filenames are reserved.

***

## Limits

These are the shipped defaults. Contact [Truv Support](mailto:support@truv.com) if your test plan needs more headroom.

| Limit | Default |
| - | - |
| Scenarios per account (all data sources) | 100 |
| Payload size | 1024 KB |
| 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 |
| MFA challenges per scenario | 5 |
| Task delay (`behavior_duration_seconds`) | 120 seconds |
| Files in the document library | 100 |
| Single file upload | 10 MB |

***

## Errors

Sandbox configuration errors use the standard [error envelope](/api-reference/errors):

```json theme={null}
{
  "error": {
    "code": "reserved_credential",
    "message": "Username 'goodlogin' is reserved for Truv's documented sandbox logins."
  }
}
```

| Code | Status | Cause |
| - | - | - |
| `env_not_sandbox` | 403 | The request authenticated with a production key. |
| `data_source_required` | 400 | The fields you set fit more than one data source. Send `data_source` explicitly. |
| `unsupported_data_source` | 400 | You set a matching field the data source doesn't accept. |
| `reserved_credential` | 400 | The login or filename collides with one of Truv's own documented sandbox credentials. The reserved set grows as Truv publishes new test credentials, so a value that was accepted before can be refused on a later edit. |
| `sandbox_limit_exceeded` | 400 | A quota from the table above would be exceeded. |
| `resource_in_use` | 409 | A document is still referenced by a scenario. |
| `incorrect_parameters` | 400 | A field failed validation, or another scenario of the same data source already has the same matching fields. `extra.invalid-params` names the field and the reason. |

***

## Next steps

<CardGroup cols={2}>
  <Card title="Income & Employment Testing" icon="briefcase" href="/developers/testing/income-employment">
    Truv's built-in payroll credentials and sample reports
  </Card>

  <Card title="Task lifecycle" icon="list-check" href="/api-reference/tasks/lifecycle">
    What each `behavior_status` means to your integration
  </Card>

  <Card title="Data refresh" icon="arrows-rotate" href="/developers/integration/embedded-orders/data-refresh">
    How refreshes reach your `is_refresh` scenarios
  </Card>

  <Card title="Sandbox API reference" icon="code" href="/api-reference/sandbox/object">
    Full request and response schemas for every sandbox endpoint
  </Card>

  <Card title="Launch checklist" icon="rocket" href="/developers/testing/launch-checklist">
    What to verify before you switch to production keys
  </Card>
</CardGroup>


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